Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Emphasis API for Highlighting Text Ranges #62

Merged
merged 7 commits into from
Dec 28, 2024
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