Skip to content

Commit 76f8364

Browse files
authored
✨ Emphasis API for Highlighting Text Ranges (#62)
* Add `cgPathFallback` property to `NSBezierPath` for macOS versions < 14 to support conversion to `CGPath` * Add convenience initializer to `NSColor` for creating color from hex value * Add `smoothPath ` method to `NSBezierPath` for smooth path creation * Add EmphasizeAPI class to manage text range emphasis with dynamic highlighting
1 parent 509d7b2 commit 76f8364

File tree

6 files changed

+448
-0
lines changed

6 files changed

+448
-0
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
//
2+
// 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+
// Set bounds of the layer; needed for the scale animation
132+
if let cgPath = layer.path {
133+
let boundingBox = cgPath.boundingBox
134+
layer.bounds = boundingBox
135+
layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY)
136+
}
137+
138+
return layer
139+
}
140+
141+
/// Shifts the active highlight to a different emphasized range based on the specified offset.
142+
///
143+
/// - Parameter amount: The offset to shift the active highlight.
144+
/// - A positive value moves to subsequent ranges.
145+
/// - A negative value moves to prior ranges.
146+
///
147+
/// - Returns: An optional `NSRange` representing the newly active highlight, colored in the active color.
148+
/// Returns `nil` if no change occurred (e.g., if there are no highlighted ranges).
149+
private func shiftActiveHighlight(amount: Int) -> NSRange? {
150+
guard !highlightedRanges.isEmpty else { return nil }
151+
152+
var currentIndex = emphasizedRangeIndex ?? -1
153+
currentIndex = (currentIndex + amount + highlightedRanges.count) % highlightedRanges.count
154+
155+
guard currentIndex < highlightedRanges.count else { return nil }
156+
157+
// Reset the previously active layer
158+
if let currentIndex = emphasizedRangeIndex {
159+
let previousLayer = highlightedRanges[currentIndex].layer
160+
previousLayer.fillColor = inactiveColor.cgColor
161+
previousLayer.shadowOpacity = 0.0
162+
}
163+
164+
// Set the new active layer
165+
let newLayer = highlightedRanges[currentIndex].layer
166+
newLayer.fillColor = activeColor.cgColor
167+
newLayer.shadowOpacity = 0.3
168+
169+
applyPopAnimation(to: newLayer)
170+
emphasizedRangeIndex = currentIndex
171+
172+
return highlightedRanges[currentIndex].range
173+
}
174+
175+
private func applyPopAnimation(to layer: CALayer) {
176+
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale")
177+
scaleAnimation.values = [1.0, 1.5, 1.0]
178+
scaleAnimation.keyTimes = [0, 0.3, 1]
179+
scaleAnimation.duration = 0.2
180+
scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)]
181+
182+
layer.add(scaleAnimation, forKey: "popAnimation")
183+
}
184+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// NSBezierPath+CGPathFallback.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Tom Ludwig on 27.11.24.
6+
//
7+
8+
import AppKit
9+
10+
extension NSBezierPath {
11+
/// Converts the `NSBezierPath` instance into a `CGPath`, providing a fallback method for compatibility(macOS < 14).
12+
public var cgPathFallback: CGPath {
13+
let path = CGMutablePath()
14+
var points = [CGPoint](repeating: .zero, count: 3)
15+
16+
for index in 0 ..< elementCount {
17+
let type = element(at: index, associatedPoints: &points)
18+
switch type {
19+
case .moveTo:
20+
path.move(to: points[0])
21+
case .lineTo:
22+
path.addLine(to: points[0])
23+
case .curveTo:
24+
path.addCurve(to: points[2], control1: points[0], control2: points[1])
25+
case .closePath:
26+
path.closeSubpath()
27+
@unknown default:
28+
continue
29+
}
30+
}
31+
32+
return path
33+
}
34+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// NSBezierPath+SmoothPath.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Tom Ludwig on 12.11.24.
6+
//
7+
8+
import AppKit
9+
import SwiftUI
10+
11+
extension NSBezierPath {
12+
private func quadCurve(to endPoint: CGPoint, controlPoint: CGPoint) {
13+
guard pointIsValid(endPoint) && pointIsValid(controlPoint) else { return }
14+
15+
let startPoint = self.currentPoint
16+
let controlPoint1 = CGPoint(x: (startPoint.x + (controlPoint.x - startPoint.x) * 2.0 / 3.0),
17+
y: (startPoint.y + (controlPoint.y - startPoint.y) * 2.0 / 3.0))
18+
let controlPoint2 = CGPoint(x: (endPoint.x + (controlPoint.x - endPoint.x) * 2.0 / 3.0),
19+
y: (endPoint.y + (controlPoint.y - endPoint.y) * 2.0 / 3.0))
20+
21+
curve(to: endPoint, controlPoint1: controlPoint1, controlPoint2: controlPoint2)
22+
}
23+
24+
private func pointIsValid(_ point: CGPoint) -> Bool {
25+
return !point.x.isNaN && !point.y.isNaN
26+
}
27+
28+
// swiftlint:disable:next function_body_length
29+
static func smoothPath(_ points: [NSPoint], radius cornerRadius: CGFloat) -> NSBezierPath {
30+
// Normalizing radius to compensate for the quadraticCurve
31+
let radius = cornerRadius * 1.15
32+
33+
let path = NSBezierPath()
34+
35+
guard points.count > 1 else { return path }
36+
37+
// Calculate the initial corner start based on the first two points
38+
let initialVector = NSPoint(x: points[1].x - points[0].x, y: points[1].y - points[0].y)
39+
let initialDistance = sqrt(initialVector.x * initialVector.x + initialVector.y * initialVector.y)
40+
41+
let initialUnitVector = NSPoint(x: initialVector.x / initialDistance, y: initialVector.y / initialDistance)
42+
let initialCornerStart = NSPoint(
43+
x: points[0].x + initialUnitVector.x * radius,
44+
y: points[0].y + initialUnitVector.y * radius
45+
)
46+
47+
// Start path at the initial corner start
48+
path.move(to: points.first == points.last ? initialCornerStart : points[0])
49+
50+
for index in 1..<points.count - 1 {
51+
let p0 = points[index - 1]
52+
let p1 = points[index]
53+
let p2 = points[index + 1]
54+
55+
// Calculate vectors
56+
let vector1 = NSPoint(x: p1.x - p0.x, y: p1.y - p0.y)
57+
let vector2 = NSPoint(x: p2.x - p1.x, y: p2.y - p1.y)
58+
59+
// Calculate unit vectors and distances
60+
let distance1 = sqrt(vector1.x * vector1.x + vector1.y * vector1.y)
61+
let distance2 = sqrt(vector2.x * vector2.x + vector2.y * vector2.y)
62+
63+
// TODO: Check if .zero should get used or just skipped
64+
if distance1.isZero || distance2.isZero { continue }
65+
let unitVector1 = distance1 > 0 ? NSPoint(x: vector1.x / distance1, y: vector1.y / distance1) : NSPoint.zero
66+
let unitVector2 = distance2 > 0 ? NSPoint(x: vector2.x / distance2, y: vector2.y / distance2) : NSPoint.zero
67+
68+
// This uses the dot product formula: cos(θ) = (u1 • u2),
69+
// where u1 and u2 are unit vectors. The result will range from -1 to 1:
70+
let angleCosine = unitVector1.x * unitVector2.x + unitVector1.y * unitVector2.y
71+
72+
// If the cosine of the angle is less than 0.5 (i.e., angle > ~60 degrees),
73+
// the radius is reduced to half to avoid overlapping or excessive smoothing.
74+
let clampedRadius = angleCosine < 0.5 ? radius /** 0.5 */: radius // Adjust for sharp angles
75+
76+
// Calculate the corner start and end
77+
let cornerStart = NSPoint(x: p1.x - unitVector1.x * radius, y: p1.y - unitVector1.y * radius)
78+
let cornerEnd = NSPoint(x: p1.x + unitVector2.x * radius, y: p1.y + unitVector2.y * radius)
79+
80+
// Check if this segment is a straight line or a curve
81+
if unitVector1 != unitVector2 { // There's a change in direction, add a curve
82+
path.line(to: cornerStart)
83+
path.quadCurve(to: cornerEnd, controlPoint: p1)
84+
} else { // Straight line, just add a line
85+
path.line(to: p1)
86+
}
87+
}
88+
89+
// Handle the final segment if the path is closed
90+
if points.first == points.last, points.count > 2 {
91+
// Closing path by rounding back to the initial point
92+
let lastPoint = points[points.count - 2]
93+
let firstPoint = points[0]
94+
95+
// Calculate the vectors and unit vectors
96+
let finalVector = NSPoint(x: firstPoint.x - lastPoint.x, y: firstPoint.y - lastPoint.y)
97+
let distance = sqrt(finalVector.x * finalVector.x + finalVector.y * finalVector.y)
98+
let unitVector = NSPoint(x: finalVector.x / distance, y: finalVector.y / distance)
99+
100+
// Calculate the final corner start and initial corner end
101+
let finalCornerStart = NSPoint(
102+
x: firstPoint.x - unitVector.x * radius,
103+
y: firstPoint.y - unitVector.y * radius
104+
)
105+
106+
let initialCornerEnd = NSPoint(
107+
x: points[0].x + initialUnitVector.x * radius,
108+
y: points[0].y + initialUnitVector.y * radius
109+
)
110+
111+
path.line(to: finalCornerStart)
112+
path.quadCurve(to: initialCornerEnd, controlPoint: firstPoint)
113+
path.close()
114+
115+
} else if let lastPoint = points.last { // For open paths, just connect to the last point
116+
path.line(to: lastPoint)
117+
}
118+
119+
return path
120+
}
121+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// NSColor+Hex.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Tom Ludwig on 27.11.24.
6+
//
7+
8+
import AppKit
9+
10+
extension NSColor {
11+
convenience init(hex: Int, alpha: Double = 1.0) {
12+
let red = (hex >> 16) & 0xFF
13+
let green = (hex >> 8) & 0xFF
14+
let blue = hex & 0xFF
15+
self.init(srgbRed: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, alpha: alpha)
16+
}
17+
}

0 commit comments

Comments
 (0)