|
| 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 | +} |
0 commit comments