From 76f83646c9f5d28d09e7b132d2a18e353a8a4f6a Mon Sep 17 00:00:00 2001 From: Tom Ludwig Date: Sat, 28 Dec 2024 15:56:38 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Emphasis=20API=20for=20Highlighting?= =?UTF-8?q?=20Text=20Ranges=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../EmphasizeAPI/EmphasizeAPI.swift | 184 ++++++++++++++++++ .../NSBezierPath+CGPathFallback.swift | 34 ++++ .../Extensions/NSBezierPath+SmoothPath.swift | 121 ++++++++++++ .../Extensions/NSColor+Hex.swift | 17 ++ .../TextLayoutManager+Public.swift | 88 +++++++++ .../CodeEditTextView/TextView/TextView.swift | 4 + 6 files changed, 448 insertions(+) create mode 100644 Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift create mode 100644 Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift create mode 100644 Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift create mode 100644 Sources/CodeEditTextView/Extensions/NSColor+Hex.swift diff --git a/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift new file mode 100644 index 00000000..2de61d2d --- /dev/null +++ b/Sources/CodeEditTextView/EmphasizeAPI/EmphasizeAPI.swift @@ -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") + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift new file mode 100644 index 00000000..dcb1a712 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+CGPathFallback.swift @@ -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 + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift b/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift new file mode 100644 index 00000000..114652f6 --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSBezierPath+SmoothPath.swift @@ -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.. 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 + } +} diff --git a/Sources/CodeEditTextView/Extensions/NSColor+Hex.swift b/Sources/CodeEditTextView/Extensions/NSColor+Hex.swift new file mode 100644 index 00000000..d52f6a0d --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSColor+Hex.swift @@ -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) + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 938950ac..34617613 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -153,6 +153,94 @@ extension TextLayoutManager { ) } + // swiftlint:disable function_body_length + /// Creates a smooth bezier path for the specified range. + /// If the range exceeds the available text, it uses the maximum available range. + /// - Parameter range: The range of text offsets to generate the path for. + /// - Returns: An `NSBezierPath` representing the visual shape for the text range, or `nil` if the range is invalid. + public func roundedPathForRange(_ range: NSRange) -> NSBezierPath? { + // Ensure the range is within the bounds of the text storage + let validRange = NSRange( + location: range.lowerBound, + length: min(range.length, lineStorage.length - range.lowerBound) + ) + + guard validRange.length > 0 else { return rectForEndOffset().map { NSBezierPath(rect: $0) } } + + var rightSidePoints: [CGPoint] = [] // Points for Bottom-right → Top-right + var leftSidePoints: [CGPoint] = [] // Points for Bottom-left → Top-left + + var currentOffset = validRange.lowerBound + + // Process each line fragment within the range + while currentOffset < validRange.upperBound { + guard let linePosition = lineStorage.getLine(atOffset: currentOffset) else { return nil } + + if linePosition.data.lineFragments.isEmpty { + let newHeight = ensureLayoutFor(position: linePosition) + if linePosition.height != newHeight { + delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height) + } + } + + guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine( + atOffset: currentOffset - linePosition.range.location + ) else { break } + + // Calculate the X positions for the range's boundaries within the fragment + let realRangeStart = (textStorage?.string as? NSString)? + .rangeOfComposedCharacterSequence(at: validRange.lowerBound) + ?? NSRange(location: validRange.lowerBound, length: 0) + + let realRangeEnd = (textStorage?.string as? NSString)? + .rangeOfComposedCharacterSequence(at: validRange.upperBound - 1) + ?? NSRange(location: validRange.upperBound - 1, length: 0) + + let minXPos = CTLineGetOffsetForStringIndex( + fragmentPosition.data.ctLine, + realRangeStart.location - linePosition.range.location, + nil + ) + edgeInsets.left + + let maxXPos = CTLineGetOffsetForStringIndex( + fragmentPosition.data.ctLine, + realRangeEnd.upperBound - linePosition.range.location, + nil + ) + edgeInsets.left + + // Ensure the fragment has a valid width + guard maxXPos > minXPos else { break } + + // Add the Y positions for the fragment + let topY = linePosition.yPos + fragmentPosition.yPos + fragmentPosition.data.scaledHeight + let bottomY = linePosition.yPos + fragmentPosition.yPos + + // Append points in the correct order + rightSidePoints.append(contentsOf: [ + CGPoint(x: maxXPos, y: bottomY), // Bottom-right + CGPoint(x: maxXPos, y: topY) // Top-right + ]) + leftSidePoints.insert(contentsOf: [ + CGPoint(x: minXPos, y: topY), // Top-left + CGPoint(x: minXPos, y: bottomY) // Bottom-left + ], at: 0) + + // Move to the next fragment + currentOffset = min(validRange.upperBound, linePosition.range.upperBound) + } + + // Combine the points in clockwise order + let points = leftSidePoints + rightSidePoints + + // Close the path + if let firstPoint = points.first { + return NSBezierPath.smoothPath(points + [firstPoint], radius: 2) + } + + return nil + } + // swiftlint:enable function_body_length + /// Finds a suitable cursor rect for the end position. /// - Returns: A CGRect if it could be created. private func rectForEndOffset() -> CGRect? { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 4c625612..48fc75c5 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -225,6 +225,9 @@ public class TextView: NSView, NSTextContent { /// The selection manager for the text view. private(set) public var selectionManager: TextSelectionManager! + /// Empasizse text ranges in the text view + public var emphasizeAPI: EmphasizeAPI? + // MARK: - Private Properties var isFirstResponder: Bool = false @@ -280,6 +283,7 @@ public class TextView: NSView, NSTextContent { super.init(frame: .zero) + self.emphasizeAPI = EmphasizeAPI(textView: self) self.storageDelegate = MultiStorageDelegate() wantsLayer = true