Skip to content

Commit fe36e69

Browse files
austincondiffthecoolwintertom-ludwig
authored
Emphasis Manager Enhancements (#78)
* Cleaned up the emphasize highlights appearance. Added black text layer. * Removed unused changes from last commit. * Enabled anti aliasing and font smoothing. Using findHighlightColor instead of a custom color. * Update EmphasizeAPI Colors On Draw * 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. * Fix Scrolling To Emphasized Ranges, Swift 6 Warning * Use flatMap * Weakly reference `layer` and `textLayer` in delayed animation * Fix nested `if` * Weakly reference `self` in delayed animation * Update docs * Move `Emphasis` and `EmphasisStyle` to Files * Remove Extra Closing Bracket * Lint Error * Comment on line drawing * Return Early From `updateSelectionViews` When Not First Responder * Docs, Naming * Docs Co-authored-by: Tom Ludwig <[email protected]> * Docs - Spelling * Add EmphasisManager Docs --------- Co-authored-by: Khan Winter <[email protected]> Co-authored-by: Tom Ludwig <[email protected]>
1 parent e86ca59 commit fe36e69

File tree

10 files changed

+421
-203
lines changed

10 files changed

+421
-203
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// Emphasis.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 3/31/25.
6+
//
7+
8+
import AppKit
9+
10+
/// Represents a single emphasis with its properties
11+
public struct Emphasis {
12+
/// The range the emphasis applies it's style to, relative to the entire text document.
13+
public let range: NSRange
14+
15+
/// The style to apply emphasis with, handled by the ``EmphasisManager``.
16+
public let style: EmphasisStyle
17+
18+
/// Set to `true` to 'flash' the emphasis before removing it automatically after being added.
19+
///
20+
/// Useful when an emphasis should be temporary and quick, like when emphasizing paired brackets in a document.
21+
public let flash: Bool
22+
23+
/// Set to `true` to style the emphasis as 'inactive'.
24+
///
25+
/// When ``style`` is ``EmphasisStyle/standard``, this reduces shadows and background color.
26+
/// For all styles, if drawing text on top of them, this uses ``EmphasisManager/getInactiveTextColor`` instead of
27+
/// the text view's text color to render the emphasized text.
28+
public let inactive: Bool
29+
30+
/// Set to `true` if the emphasis manager should update the text view's selected range to match
31+
/// this object's ``Emphasis/range`` value.
32+
public let selectInDocument: Bool
33+
34+
public init(
35+
range: NSRange,
36+
style: EmphasisStyle = .standard,
37+
flash: Bool = false,
38+
inactive: Bool = false,
39+
selectInDocument: Bool = false
40+
) {
41+
self.range = range
42+
self.style = style
43+
self.flash = flash
44+
self.inactive = inactive
45+
self.selectInDocument = selectInDocument
46+
}
47+
}
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
//
2+
// EmphasisManager.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Tom Ludwig on 05.11.24.
6+
//
7+
8+
import AppKit
9+
10+
/// Manages text emphases within a text view, supporting multiple styles and groups.
11+
///
12+
/// Text emphasis draws attention to a range of text, indicating importance.
13+
/// This object may be used in a code editor to emphasize search results, or indicate
14+
/// bracket pairs, for instance.
15+
///
16+
/// This object is designed to allow for easy grouping of emphasis types. An outside
17+
/// object is responsible for managing what emphases are visible. Because it's very
18+
/// likely that more than one type of emphasis may occur on the document at the same
19+
/// time, grouping allows each emphasis to be managed separately from the others by
20+
/// each outside object without knowledge of the other's state.
21+
public final class EmphasisManager {
22+
/// Internal representation of a emphasis layer with its associated text layer
23+
private struct EmphasisLayer {
24+
let emphasis: Emphasis
25+
let layer: CAShapeLayer
26+
let textLayer: CATextLayer?
27+
}
28+
29+
private var emphasisGroups: [String: [EmphasisLayer]] = [:]
30+
private let activeColor: NSColor = .findHighlightColor
31+
private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4)
32+
private var originalSelectionColor: NSColor?
33+
34+
weak var textView: TextView?
35+
36+
init(textView: TextView) {
37+
self.textView = textView
38+
}
39+
40+
/// Adds a single emphasis to the specified group.
41+
/// - Parameters:
42+
/// - emphasis: The emphasis to add
43+
/// - id: A group identifier
44+
public func addEmphasis(_ emphasis: Emphasis, for id: String) {
45+
addEmphases([emphasis], for: id)
46+
}
47+
48+
/// Adds multiple emphases to the specified group.
49+
/// - Parameters:
50+
/// - emphases: The emphases to add
51+
/// - id: The group identifier
52+
public func addEmphases(_ emphases: [Emphasis], for id: String) {
53+
// Store the current selection background color if not already stored
54+
if originalSelectionColor == nil {
55+
originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor
56+
}
57+
58+
let layers = emphases.map { createEmphasisLayer(for: $0) }
59+
emphasisGroups[id] = layers
60+
61+
// Handle selections
62+
handleSelections(for: emphases)
63+
64+
// Handle flash animations
65+
for (index, emphasis) in emphases.enumerated() where emphasis.flash {
66+
let layer = layers[index]
67+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
68+
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 {
74+
self.emphasisGroups.removeValue(forKey: id)
75+
} else {
76+
self.emphasisGroups[id] = emphases
77+
}
78+
}
79+
}
80+
}
81+
}
82+
83+
/// Replaces all emphases in the specified group.
84+
/// - Parameters:
85+
/// - emphases: The new emphases
86+
/// - id: The group identifier
87+
public func replaceEmphases(_ emphases: [Emphasis], for id: String) {
88+
removeEmphases(for: id)
89+
addEmphases(emphases, for: id)
90+
}
91+
92+
/// Updates the emphases for a group by transforming the existing array.
93+
/// - Parameters:
94+
/// - id: The group identifier
95+
/// - transform: The transformation to apply to the existing emphases
96+
public func updateEmphases(for id: String, _ transform: ([Emphasis]) -> [Emphasis]) {
97+
guard let existingLayers = emphasisGroups[id] else { return }
98+
let existingEmphases = existingLayers.map { $0.emphasis }
99+
let newEmphases = transform(existingEmphases)
100+
replaceEmphases(newEmphases, for: id)
101+
}
102+
103+
/// Removes all emphases for the given group.
104+
/// - Parameter id: The group identifier
105+
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()
111+
}
112+
emphasisGroups[id] = nil
113+
}
114+
115+
/// Removes all emphases for all groups.
116+
public func removeAllEmphases() {
117+
emphasisGroups.keys.forEach { removeEmphases(for: $0) }
118+
emphasisGroups.removeAll()
119+
120+
// Restore original selection emphasising
121+
if let originalColor = originalSelectionColor {
122+
textView?.selectionManager.selectionBackgroundColor = originalColor
123+
}
124+
originalSelectionColor = nil
125+
}
126+
127+
/// Gets all emphases for a given group.
128+
/// - Parameter id: The group identifier
129+
/// - Returns: Array of emphases in the group
130+
public func getEmphases(for id: String) -> [Emphasis] {
131+
emphasisGroups[id]?.map { $0.emphasis } ?? []
132+
}
133+
134+
/// Updates the positions and bounds of all emphasis layers to match the current text layout.
135+
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+
}
143+
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+
}
150+
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+
}
157+
}
158+
}
159+
}
160+
161+
private func createEmphasisLayer(for emphasis: Emphasis) -> EmphasisLayer {
162+
guard let shapePath = textView?.layoutManager?.roundedPathForRange(emphasis.range) else {
163+
return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil)
164+
}
165+
166+
let layer = createShapeLayer(shapePath: shapePath, emphasis: emphasis)
167+
textView?.layer?.insertSublayer(layer, at: 1)
168+
169+
let textLayer = createTextLayer(for: emphasis)
170+
if let textLayer = textLayer {
171+
textView?.layer?.addSublayer(textLayer)
172+
}
173+
174+
if emphasis.inactive == false && emphasis.style == .standard {
175+
applyPopAnimation(to: layer)
176+
}
177+
178+
return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer)
179+
}
180+
181+
private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer {
182+
let layer = CAShapeLayer()
183+
184+
switch emphasis.style {
185+
case .standard:
186+
layer.cornerRadius = 4.0
187+
layer.fillColor = (emphasis.inactive ? inactiveColor : activeColor).cgColor
188+
layer.shadowColor = .black
189+
layer.shadowOpacity = emphasis.inactive ? 0.0 : 0.5
190+
layer.shadowOffset = CGSize(width: 0, height: 1.5)
191+
layer.shadowRadius = 1.5
192+
layer.opacity = 1.0
193+
layer.zPosition = emphasis.inactive ? 0 : 1
194+
case .underline(let color):
195+
layer.lineWidth = 1.0
196+
layer.lineCap = .round
197+
layer.strokeColor = color.cgColor
198+
layer.fillColor = nil
199+
layer.opacity = emphasis.flash ? 0.0 : 1.0
200+
layer.zPosition = 1
201+
case .outline(let color):
202+
layer.cornerRadius = 2.5
203+
layer.borderColor = color.cgColor
204+
layer.borderWidth = 0.5
205+
layer.fillColor = nil
206+
layer.opacity = emphasis.flash ? 0.0 : 1.0
207+
layer.zPosition = 1
208+
}
209+
210+
if #available(macOS 14.0, *) {
211+
layer.path = shapePath.cgPath
212+
} else {
213+
layer.path = shapePath.cgPathFallback
214+
}
215+
216+
// Set bounds of the layer; needed for the scale animation
217+
if let cgPath = layer.path {
218+
let boundingBox = cgPath.boundingBox
219+
layer.bounds = boundingBox
220+
layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
221+
}
222+
223+
return layer
224+
}
225+
226+
private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? {
227+
guard let textView = textView,
228+
let layoutManager = textView.layoutManager,
229+
let shapePath = layoutManager.roundedPathForRange(emphasis.range),
230+
let originalString = textView.textStorage?.attributedSubstring(from: emphasis.range) else {
231+
return nil
232+
}
233+
234+
var bounds = shapePath.bounds
235+
bounds.origin.y += 1 // Move down by 1 pixel
236+
237+
// Create text layer
238+
let textLayer = CATextLayer()
239+
textLayer.frame = bounds
240+
textLayer.backgroundColor = NSColor.clear.cgColor
241+
textLayer.contentsScale = textView.window?.screen?.backingScaleFactor ?? 2.0
242+
textLayer.allowsFontSubpixelQuantization = true
243+
textLayer.zPosition = 2
244+
245+
// Get the font from the attributed string
246+
if let font = originalString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont {
247+
textLayer.font = font
248+
} else {
249+
textLayer.font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
250+
}
251+
252+
updateTextLayer(textLayer, with: originalString, emphasis: emphasis)
253+
return textLayer
254+
}
255+
256+
private func updateTextLayer(
257+
_ textLayer: CATextLayer,
258+
with originalString: NSAttributedString,
259+
emphasis: Emphasis
260+
) {
261+
let text = NSMutableAttributedString(attributedString: originalString)
262+
text.addAttribute(
263+
.foregroundColor,
264+
value: emphasis.inactive ? getInactiveTextColor() : NSColor.black,
265+
range: NSRange(location: 0, length: text.length)
266+
)
267+
textLayer.string = text
268+
}
269+
270+
private func getInactiveTextColor() -> NSColor {
271+
if textView?.effectiveAppearance.name == .darkAqua {
272+
return .white
273+
}
274+
return .black
275+
}
276+
277+
private func applyPopAnimation(to layer: CALayer) {
278+
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
279+
scaleAnimation.values = [1.0, 1.25, 1.0]
280+
scaleAnimation.keyTimes = [0, 0.3, 1]
281+
scaleAnimation.duration = 0.1
282+
scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)]
283+
284+
layer.add(scaleAnimation, forKey: "popAnimation")
285+
}
286+
287+
private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?) {
288+
let fadeAnimation = CABasicAnimation(keyPath: "opacity")
289+
fadeAnimation.fromValue = 1.0
290+
fadeAnimation.toValue = 0.0
291+
fadeAnimation.duration = 0.1
292+
fadeAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut)
293+
fadeAnimation.fillMode = .forwards
294+
fadeAnimation.isRemovedOnCompletion = false
295+
296+
layer.add(fadeAnimation, forKey: "fadeOutAnimation")
297+
298+
if let textLayer = textLayer, let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation {
299+
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation")
300+
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation")
301+
}
302+
303+
// Remove both layers after animation completes
304+
DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { [weak layer, weak textLayer] in
305+
layer?.removeFromSuperlayer()
306+
textLayer?.removeFromSuperlayer()
307+
}
308+
}
309+
310+
/// Handles selection of text ranges for emphases where select is true
311+
private func handleSelections(for emphases: [Emphasis]) {
312+
let selectableRanges = emphases.filter(\.selectInDocument).map(\.range)
313+
guard let textView, !selectableRanges.isEmpty else { return }
314+
315+
textView.selectionManager.setSelectedRanges(selectableRanges)
316+
textView.scrollSelectionToVisible()
317+
textView.needsDisplay = true
318+
}
319+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// EmphasisStyle.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 3/31/25.
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+
}

0 commit comments

Comments
 (0)