Skip to content

Commit

Permalink
✨ Emphasis API for Highlighting Text Ranges (#62)
Browse files Browse the repository at this point in the history
* 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
tom-ludwig authored Dec 28, 2024
1 parent 509d7b2 commit 76f8364
Show file tree
Hide file tree
Showing 6 changed files with 448 additions and 0 deletions.
184 changes: 184 additions & 0 deletions Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift
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")
}
}
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 Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift
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
}
}
17 changes: 17 additions & 0 deletions Sources/CodeEditTextView/Extensions/NSColor+Hex.swift
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)
}
}
Loading

0 comments on commit 76f8364

Please sign in to comment.