Skip to content

Commit 938c9da

Browse files
committed
Add EmphasizeAPI class to manage text range emphasis with dynamic highlighting
1 parent 0f2d3a1 commit 938c9da

File tree

2 files changed

+184
-0
lines changed

2 files changed

+184
-0
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//
2+
// TextView+EmphasizeAPI.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Tom Ludwig on 05.11.24.
6+
//
7+
8+
import AppKit
9+
10+
/// Emphasizes text ranges within a given text view.
11+
public class EmphasizeAPI {
12+
// MARK: - Properties
13+
14+
private var highlightedRanges: [EmphasizedRange] = []
15+
private var emphasizedRangeIndex: Int?
16+
private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1)
17+
private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4)
18+
19+
weak var textView: TextView?
20+
21+
init(textView: TextView) {
22+
self.textView = textView
23+
}
24+
25+
// MARK: - Structs
26+
private struct EmphasizedRange {
27+
var range: NSRange
28+
var layer: CAShapeLayer
29+
}
30+
31+
// MARK: - Public Methods
32+
33+
/// Emphasises multiple ranges, with one optionally marked as active (highlighted usually in yellow).
34+
///
35+
/// - Parameters:
36+
/// - ranges: An array of ranges to highlight.
37+
/// - activeIndex: The index of the range to highlight in yellow. Defaults to `nil`.
38+
/// - clearPrevious: Removes previous emphasised ranges. Defaults to `true`.
39+
public func emphasizeRanges(ranges: [NSRange], activeIndex: Int? = nil, clearPrevious: Bool = true) {
40+
if clearPrevious {
41+
removeEmphasizeLayers() // Clear all existing highlights
42+
}
43+
44+
ranges.enumerated().forEach { index, range in
45+
let isActive = (index == activeIndex)
46+
emphasizeRange(range: range, active: isActive)
47+
48+
if isActive {
49+
emphasizedRangeIndex = activeIndex
50+
}
51+
}
52+
}
53+
54+
/// Emphasises a single range.
55+
/// - Parameters:
56+
/// - range: The text range to highlight.
57+
/// - active: Whether the range should be highlighted as active (usually in yellow). Defaults to `false`.
58+
public func emphasizeRange(range: NSRange, active: Bool = false) {
59+
guard let shapePath = textView?.layoutManager?.roundedPathForRange(range) else { return }
60+
61+
let layer = createEmphasizeLayer(shapePath: shapePath, active: active)
62+
textView?.layer?.insertSublayer(layer, at: 1)
63+
64+
highlightedRanges.append(EmphasizedRange(range: range, layer: layer))
65+
}
66+
67+
/// Removes the highlight for a specific range.
68+
/// - Parameter range: The range to remove.
69+
public func removeHighlightForRange(_ range: NSRange) {
70+
guard let index = highlightedRanges.firstIndex(where: { $0.range == range }) else { return }
71+
72+
let removedLayer = highlightedRanges[index].layer
73+
removedLayer.removeFromSuperlayer()
74+
75+
highlightedRanges.remove(at: index)
76+
77+
// Adjust the active highlight index
78+
if let currentIndex = emphasizedRangeIndex {
79+
if currentIndex == index {
80+
// TODO: What is the desired behaviour here?
81+
emphasizedRangeIndex = nil // Reset if the active highlight is removed
82+
} else if currentIndex > index {
83+
emphasizedRangeIndex = currentIndex - 1 // Shift if the removed index was before the active index
84+
}
85+
}
86+
}
87+
88+
/// Highlights the previous emphasised range (usually in yellow).
89+
///
90+
/// - Returns: An optional `NSRange` representing the newly active emphasized range.
91+
/// Returns `nil` if there are no prior ranges to highlight.
92+
@discardableResult
93+
public func highlightPrevious() -> NSRange? {
94+
return shiftActiveHighlight(amount: -1)
95+
}
96+
97+
/// Highlights the next emphasised range (usually in yellow).
98+
///
99+
/// - Returns: An optional `NSRange` representing the newly active emphasized range.
100+
/// Returns `nil` if there are no subsequent ranges to highlight.
101+
@discardableResult
102+
public func highlightNext() -> NSRange? {
103+
return shiftActiveHighlight(amount: 1)
104+
}
105+
106+
/// Removes all emphasised ranges.
107+
public func removeEmphasizeLayers() {
108+
highlightedRanges.forEach { $0.layer.removeFromSuperlayer() }
109+
highlightedRanges.removeAll()
110+
emphasizedRangeIndex = nil
111+
}
112+
113+
// MARK: - Private Methods
114+
115+
private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer {
116+
let layer = CAShapeLayer()
117+
layer.cornerRadius = 3.0
118+
layer.fillColor = (active ? activeColor : inactiveColor).cgColor
119+
layer.shadowColor = .black
120+
layer.shadowOpacity = active ? 0.3 : 0.0
121+
layer.shadowOffset = CGSize(width: 0, height: 1)
122+
layer.shadowRadius = 3.0
123+
layer.opacity = 1.0
124+
125+
if #available(macOS 14.0, *) {
126+
layer.path = shapePath.cgPath
127+
} else {
128+
layer.path = shapePath.cgPathFallback
129+
}
130+
131+
return layer
132+
}
133+
134+
/// Shifts the active highlight to a different emphasized range based on the specified offset.
135+
///
136+
/// - Parameter amount: The offset to shift the active highlight.
137+
/// - A positive value moves to subsequent ranges.
138+
/// - A negative value moves to prior ranges.
139+
///
140+
/// - Returns: An optional `NSRange` representing the newly active highlight, colored in the active color.
141+
/// Returns `nil` if no change occurred (e.g., if there are no highlighted ranges).
142+
private func shiftActiveHighlight(amount: Int) -> NSRange? {
143+
guard !highlightedRanges.isEmpty else { return nil }
144+
145+
var currentIndex = emphasizedRangeIndex ?? -1
146+
currentIndex = (currentIndex + amount + highlightedRanges.count) % highlightedRanges.count
147+
148+
guard currentIndex < highlightedRanges.count else { return nil }
149+
150+
// Reset the previously active layer
151+
if let currentIndex = emphasizedRangeIndex {
152+
let previousLayer = highlightedRanges[currentIndex].layer
153+
previousLayer.fillColor = inactiveColor.cgColor
154+
previousLayer.shadowOpacity = 0.0
155+
}
156+
157+
// Set the new active layer
158+
let newLayer = highlightedRanges[currentIndex].layer
159+
newLayer.fillColor = activeColor.cgColor
160+
newLayer.shadowOpacity = 0.3
161+
162+
applyPopAnimation(to: newLayer)
163+
emphasizedRangeIndex = currentIndex
164+
165+
return highlightedRanges[currentIndex].range
166+
}
167+
168+
private func applyPopAnimation(to layer: CALayer) {
169+
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
170+
scaleAnimation.values = [1.0, 1.01, 1.0]
171+
scaleAnimation.keyTimes = [0, 0.5, 1]
172+
scaleAnimation.duration = 0.1
173+
scaleAnimation.timingFunctions = [
174+
CAMediaTimingFunction(name: .easeInEaseOut),
175+
CAMediaTimingFunction(name: .easeInEaseOut)
176+
]
177+
178+
layer.add(scaleAnimation, forKey: "popAnimation")
179+
}
180+
}

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ public class TextView: NSView, NSTextContent {
225225
/// The selection manager for the text view.
226226
private(set) public var selectionManager: TextSelectionManager!
227227

228+
/// Empasizse text ranges in the text view
229+
public var emphasizeAPI: EmphasizeAPI?
230+
228231
// MARK: - Private Properties
229232

230233
var isFirstResponder: Bool = false
@@ -280,6 +283,7 @@ public class TextView: NSView, NSTextContent {
280283

281284
super.init(frame: .zero)
282285

286+
self.emphasizeAPI = EmphasizeAPI(textView: self)
283287
self.storageDelegate = MultiStorageDelegate()
284288

285289
wantsLayer = true

0 commit comments

Comments
 (0)