-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ 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
- Loading branch information
1 parent
509d7b2
commit 76f8364
Showing
6 changed files
with
448 additions
and
0 deletions.
There are no files selected for viewing
184 changes: 184 additions & 0 deletions
184
Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
// | ||
// EmphasizeAPI.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Tom Ludwig on 05.11.24. | ||
// | ||
|
||
import AppKit | ||
|
||
/// Emphasizes text ranges within a given text view. | ||
public class EmphasizeAPI { | ||
// MARK: - Properties | ||
|
||
private var highlightedRanges: [EmphasizedRange] = [] | ||
private var emphasizedRangeIndex: Int? | ||
private let activeColor: NSColor = NSColor(hex: 0xFFFB00, alpha: 1) | ||
private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) | ||
|
||
weak var textView: TextView? | ||
|
||
init(textView: TextView) { | ||
self.textView = textView | ||
} | ||
|
||
// MARK: - Structs | ||
private struct EmphasizedRange { | ||
var range: NSRange | ||
var layer: CAShapeLayer | ||
} | ||
|
||
// MARK: - Public Methods | ||
|
||
/// Emphasises multiple ranges, with one optionally marked as active (highlighted usually in yellow). | ||
/// | ||
/// - Parameters: | ||
/// - ranges: An array of ranges to highlight. | ||
/// - activeIndex: The index of the range to highlight in yellow. Defaults to `nil`. | ||
/// - clearPrevious: Removes previous emphasised ranges. Defaults to `true`. | ||
public func emphasizeRanges(ranges: [NSRange], activeIndex: Int? = nil, clearPrevious: Bool = true) { | ||
if clearPrevious { | ||
removeEmphasizeLayers() // Clear all existing highlights | ||
} | ||
|
||
ranges.enumerated().forEach { index, range in | ||
let isActive = (index == activeIndex) | ||
emphasizeRange(range: range, active: isActive) | ||
|
||
if isActive { | ||
emphasizedRangeIndex = activeIndex | ||
} | ||
} | ||
} | ||
|
||
/// Emphasises a single range. | ||
/// - Parameters: | ||
/// - range: The text range to highlight. | ||
/// - active: Whether the range should be highlighted as active (usually in yellow). Defaults to `false`. | ||
public func emphasizeRange(range: NSRange, active: Bool = false) { | ||
guard let shapePath = textView?.layoutManager?.roundedPathForRange(range) else { return } | ||
|
||
let layer = createEmphasizeLayer(shapePath: shapePath, active: active) | ||
textView?.layer?.insertSublayer(layer, at: 1) | ||
|
||
highlightedRanges.append(EmphasizedRange(range: range, layer: layer)) | ||
} | ||
|
||
/// Removes the highlight for a specific range. | ||
/// - Parameter range: The range to remove. | ||
public func removeHighlightForRange(_ range: NSRange) { | ||
guard let index = highlightedRanges.firstIndex(where: { $0.range == range }) else { return } | ||
|
||
let removedLayer = highlightedRanges[index].layer | ||
removedLayer.removeFromSuperlayer() | ||
|
||
highlightedRanges.remove(at: index) | ||
|
||
// Adjust the active highlight index | ||
if let currentIndex = emphasizedRangeIndex { | ||
if currentIndex == index { | ||
// TODO: What is the desired behaviour here? | ||
emphasizedRangeIndex = nil // Reset if the active highlight is removed | ||
} else if currentIndex > index { | ||
emphasizedRangeIndex = currentIndex - 1 // Shift if the removed index was before the active index | ||
} | ||
} | ||
} | ||
|
||
/// Highlights the previous emphasised range (usually in yellow). | ||
/// | ||
/// - Returns: An optional `NSRange` representing the newly active emphasized range. | ||
/// Returns `nil` if there are no prior ranges to highlight. | ||
@discardableResult | ||
public func highlightPrevious() -> NSRange? { | ||
return shiftActiveHighlight(amount: -1) | ||
} | ||
|
||
/// Highlights the next emphasised range (usually in yellow). | ||
/// | ||
/// - Returns: An optional `NSRange` representing the newly active emphasized range. | ||
/// Returns `nil` if there are no subsequent ranges to highlight. | ||
@discardableResult | ||
public func highlightNext() -> NSRange? { | ||
return shiftActiveHighlight(amount: 1) | ||
} | ||
|
||
/// Removes all emphasised ranges. | ||
public func removeEmphasizeLayers() { | ||
highlightedRanges.forEach { $0.layer.removeFromSuperlayer() } | ||
highlightedRanges.removeAll() | ||
emphasizedRangeIndex = nil | ||
} | ||
|
||
// MARK: - Private Methods | ||
|
||
private func createEmphasizeLayer(shapePath: NSBezierPath, active: Bool) -> CAShapeLayer { | ||
let layer = CAShapeLayer() | ||
layer.cornerRadius = 3.0 | ||
layer.fillColor = (active ? activeColor : inactiveColor).cgColor | ||
layer.shadowColor = .black | ||
layer.shadowOpacity = active ? 0.3 : 0.0 | ||
layer.shadowOffset = CGSize(width: 0, height: 1) | ||
layer.shadowRadius = 3.0 | ||
layer.opacity = 1.0 | ||
|
||
if #available(macOS 14.0, *) { | ||
layer.path = shapePath.cgPath | ||
} else { | ||
layer.path = shapePath.cgPathFallback | ||
} | ||
|
||
// Set bounds of the layer; needed for the scale animation | ||
if let cgPath = layer.path { | ||
let boundingBox = cgPath.boundingBox | ||
layer.bounds = boundingBox | ||
layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) | ||
} | ||
|
||
return layer | ||
} | ||
|
||
/// Shifts the active highlight to a different emphasized range based on the specified offset. | ||
/// | ||
/// - Parameter amount: The offset to shift the active highlight. | ||
/// - A positive value moves to subsequent ranges. | ||
/// - A negative value moves to prior ranges. | ||
/// | ||
/// - Returns: An optional `NSRange` representing the newly active highlight, colored in the active color. | ||
/// Returns `nil` if no change occurred (e.g., if there are no highlighted ranges). | ||
private func shiftActiveHighlight(amount: Int) -> NSRange? { | ||
guard !highlightedRanges.isEmpty else { return nil } | ||
|
||
var currentIndex = emphasizedRangeIndex ?? -1 | ||
currentIndex = (currentIndex + amount + highlightedRanges.count) % highlightedRanges.count | ||
|
||
guard currentIndex < highlightedRanges.count else { return nil } | ||
|
||
// Reset the previously active layer | ||
if let currentIndex = emphasizedRangeIndex { | ||
let previousLayer = highlightedRanges[currentIndex].layer | ||
previousLayer.fillColor = inactiveColor.cgColor | ||
previousLayer.shadowOpacity = 0.0 | ||
} | ||
|
||
// Set the new active layer | ||
let newLayer = highlightedRanges[currentIndex].layer | ||
newLayer.fillColor = activeColor.cgColor | ||
newLayer.shadowOpacity = 0.3 | ||
|
||
applyPopAnimation(to: newLayer) | ||
emphasizedRangeIndex = currentIndex | ||
|
||
return highlightedRanges[currentIndex].range | ||
} | ||
|
||
private func applyPopAnimation(to layer: CALayer) { | ||
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") | ||
scaleAnimation.values = [1.0, 1.5, 1.0] | ||
scaleAnimation.keyTimes = [0, 0.3, 1] | ||
scaleAnimation.duration = 0.2 | ||
scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)] | ||
|
||
layer.add(scaleAnimation, forKey: "popAnimation") | ||
} | ||
} |
34 changes: 34 additions & 0 deletions
34
Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
// | ||
// NSBezierPath+CGPathFallback.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Tom Ludwig on 27.11.24. | ||
// | ||
|
||
import AppKit | ||
|
||
extension NSBezierPath { | ||
/// Converts the `NSBezierPath` instance into a `CGPath`, providing a fallback method for compatibility(macOS < 14). | ||
public var cgPathFallback: CGPath { | ||
let path = CGMutablePath() | ||
var points = [CGPoint](repeating: .zero, count: 3) | ||
|
||
for index in 0 ..< elementCount { | ||
let type = element(at: index, associatedPoints: &points) | ||
switch type { | ||
case .moveTo: | ||
path.move(to: points[0]) | ||
case .lineTo: | ||
path.addLine(to: points[0]) | ||
case .curveTo: | ||
path.addCurve(to: points[2], control1: points[0], control2: points[1]) | ||
case .closePath: | ||
path.closeSubpath() | ||
@unknown default: | ||
continue | ||
} | ||
} | ||
|
||
return path | ||
} | ||
} |
121 changes: 121 additions & 0 deletions
121
Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
// | ||
// NSBezierPath+SmoothPath.swift | ||
// CodeEditSourceEditor | ||
// | ||
// Created by Tom Ludwig on 12.11.24. | ||
// | ||
|
||
import AppKit | ||
import SwiftUI | ||
|
||
extension NSBezierPath { | ||
private func quadCurve(to endPoint: CGPoint, controlPoint: CGPoint) { | ||
guard pointIsValid(endPoint) && pointIsValid(controlPoint) else { return } | ||
|
||
let startPoint = self.currentPoint | ||
let controlPoint1 = CGPoint(x: (startPoint.x + (controlPoint.x - startPoint.x) * 2.0 / 3.0), | ||
y: (startPoint.y + (controlPoint.y - startPoint.y) * 2.0 / 3.0)) | ||
let controlPoint2 = CGPoint(x: (endPoint.x + (controlPoint.x - endPoint.x) * 2.0 / 3.0), | ||
y: (endPoint.y + (controlPoint.y - endPoint.y) * 2.0 / 3.0)) | ||
|
||
curve(to: endPoint, controlPoint1: controlPoint1, controlPoint2: controlPoint2) | ||
} | ||
|
||
private func pointIsValid(_ point: CGPoint) -> Bool { | ||
return !point.x.isNaN && !point.y.isNaN | ||
} | ||
|
||
// swiftlint:disable:next function_body_length | ||
static func smoothPath(_ points: [NSPoint], radius cornerRadius: CGFloat) -> NSBezierPath { | ||
// Normalizing radius to compensate for the quadraticCurve | ||
let radius = cornerRadius * 1.15 | ||
|
||
let path = NSBezierPath() | ||
|
||
guard points.count > 1 else { return path } | ||
|
||
// Calculate the initial corner start based on the first two points | ||
let initialVector = NSPoint(x: points[1].x - points[0].x, y: points[1].y - points[0].y) | ||
let initialDistance = sqrt(initialVector.x * initialVector.x + initialVector.y * initialVector.y) | ||
|
||
let initialUnitVector = NSPoint(x: initialVector.x / initialDistance, y: initialVector.y / initialDistance) | ||
let initialCornerStart = NSPoint( | ||
x: points[0].x + initialUnitVector.x * radius, | ||
y: points[0].y + initialUnitVector.y * radius | ||
) | ||
|
||
// Start path at the initial corner start | ||
path.move(to: points.first == points.last ? initialCornerStart : points[0]) | ||
|
||
for index in 1..<points.count - 1 { | ||
let p0 = points[index - 1] | ||
let p1 = points[index] | ||
let p2 = points[index + 1] | ||
|
||
// Calculate vectors | ||
let vector1 = NSPoint(x: p1.x - p0.x, y: p1.y - p0.y) | ||
let vector2 = NSPoint(x: p2.x - p1.x, y: p2.y - p1.y) | ||
|
||
// Calculate unit vectors and distances | ||
let distance1 = sqrt(vector1.x * vector1.x + vector1.y * vector1.y) | ||
let distance2 = sqrt(vector2.x * vector2.x + vector2.y * vector2.y) | ||
|
||
// TODO: Check if .zero should get used or just skipped | ||
if distance1.isZero || distance2.isZero { continue } | ||
let unitVector1 = distance1 > 0 ? NSPoint(x: vector1.x / distance1, y: vector1.y / distance1) : NSPoint.zero | ||
let unitVector2 = distance2 > 0 ? NSPoint(x: vector2.x / distance2, y: vector2.y / distance2) : NSPoint.zero | ||
|
||
// This uses the dot product formula: cos(θ) = (u1 • u2), | ||
// where u1 and u2 are unit vectors. The result will range from -1 to 1: | ||
let angleCosine = unitVector1.x * unitVector2.x + unitVector1.y * unitVector2.y | ||
|
||
// If the cosine of the angle is less than 0.5 (i.e., angle > ~60 degrees), | ||
// the radius is reduced to half to avoid overlapping or excessive smoothing. | ||
let clampedRadius = angleCosine < 0.5 ? radius /** 0.5 */: radius // Adjust for sharp angles | ||
|
||
// Calculate the corner start and end | ||
let cornerStart = NSPoint(x: p1.x - unitVector1.x * radius, y: p1.y - unitVector1.y * radius) | ||
let cornerEnd = NSPoint(x: p1.x + unitVector2.x * radius, y: p1.y + unitVector2.y * radius) | ||
|
||
// Check if this segment is a straight line or a curve | ||
if unitVector1 != unitVector2 { // There's a change in direction, add a curve | ||
path.line(to: cornerStart) | ||
path.quadCurve(to: cornerEnd, controlPoint: p1) | ||
} else { // Straight line, just add a line | ||
path.line(to: p1) | ||
} | ||
} | ||
|
||
// Handle the final segment if the path is closed | ||
if points.first == points.last, points.count > 2 { | ||
// Closing path by rounding back to the initial point | ||
let lastPoint = points[points.count - 2] | ||
let firstPoint = points[0] | ||
|
||
// Calculate the vectors and unit vectors | ||
let finalVector = NSPoint(x: firstPoint.x - lastPoint.x, y: firstPoint.y - lastPoint.y) | ||
let distance = sqrt(finalVector.x * finalVector.x + finalVector.y * finalVector.y) | ||
let unitVector = NSPoint(x: finalVector.x / distance, y: finalVector.y / distance) | ||
|
||
// Calculate the final corner start and initial corner end | ||
let finalCornerStart = NSPoint( | ||
x: firstPoint.x - unitVector.x * radius, | ||
y: firstPoint.y - unitVector.y * radius | ||
) | ||
|
||
let initialCornerEnd = NSPoint( | ||
x: points[0].x + initialUnitVector.x * radius, | ||
y: points[0].y + initialUnitVector.y * radius | ||
) | ||
|
||
path.line(to: finalCornerStart) | ||
path.quadCurve(to: initialCornerEnd, controlPoint: firstPoint) | ||
path.close() | ||
|
||
} else if let lastPoint = points.last { // For open paths, just connect to the last point | ||
path.line(to: lastPoint) | ||
} | ||
|
||
return path | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
// | ||
// NSColor+Hex.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Tom Ludwig on 27.11.24. | ||
// | ||
|
||
import AppKit | ||
|
||
extension NSColor { | ||
convenience init(hex: Int, alpha: Double = 1.0) { | ||
let red = (hex >> 16) & 0xFF | ||
let green = (hex >> 8) & 0xFF | ||
let blue = hex & 0xFF | ||
self.init(srgbRed: Double(red) / 255, green: Double(green) / 255, blue: Double(blue) / 255, alpha: alpha) | ||
} | ||
} |
Oops, something went wrong.