|
| 1 | +// |
| 2 | +// STTextViewController+HighlightRange.swift |
| 3 | +// CodeEditTextView |
| 4 | +// |
| 5 | +// Created by Khan Winter on 4/26/23. |
| 6 | +// |
| 7 | + |
| 8 | +import AppKit |
| 9 | +import STTextView |
| 10 | + |
| 11 | +extension STTextViewController { |
| 12 | + /// Highlights bracket pairs using the current selection. |
| 13 | + internal func highlightSelectionPairs() { |
| 14 | + guard bracketPairHighlight != nil else { return } |
| 15 | + removeHighlightLayers() |
| 16 | + for selection in textView.textLayoutManager.textSelections.flatMap(\.textRanges) { |
| 17 | + if selection.isEmpty, |
| 18 | + let range = selection.nsRange(using: textView.textContentManager), |
| 19 | + range.location > 0, // Range is not the beginning of the document |
| 20 | + let preceedingCharacter = textView.textContentStorage?.textStorage?.substring( |
| 21 | + from: NSRange(location: range.location - 1, length: 1) // The preceeding character exists |
| 22 | + ) { |
| 23 | + for pair in BracketPairs.allValues { |
| 24 | + if preceedingCharacter == pair.0 { |
| 25 | + // Walk forwards |
| 26 | + if let characterIndex = findClosingPair( |
| 27 | + pair.0, |
| 28 | + pair.1, |
| 29 | + from: range.location, |
| 30 | + limit: min(NSMaxRange(textView.visibleTextRange ?? .zero) + 4096, |
| 31 | + NSMaxRange(textView.documentRange)), |
| 32 | + reverse: false |
| 33 | + ) { |
| 34 | + highlightRange(NSRange(location: characterIndex, length: 1)) |
| 35 | + if bracketPairHighlight?.highlightsSourceBracket ?? false { |
| 36 | + highlightRange(NSRange(location: range.location - 1, length: 1)) |
| 37 | + } |
| 38 | + } |
| 39 | + } else if preceedingCharacter == pair.1 && range.location - 1 > 0 { |
| 40 | + // Walk backwards |
| 41 | + if let characterIndex = findClosingPair( |
| 42 | + pair.1, |
| 43 | + pair.0, |
| 44 | + from: range.location - 1, |
| 45 | + limit: max((textView.visibleTextRange?.location ?? 0) - 4096, |
| 46 | + textView.documentRange.location), |
| 47 | + reverse: true |
| 48 | + ) { |
| 49 | + highlightRange(NSRange(location: characterIndex, length: 1)) |
| 50 | + if bracketPairHighlight?.highlightsSourceBracket ?? false { |
| 51 | + highlightRange(NSRange(location: range.location - 1, length: 1)) |
| 52 | + } |
| 53 | + } |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + /// Finds a closing character given a pair of characters, ignores pairs inside the given pair. |
| 61 | + /// |
| 62 | + /// ```pseudocode |
| 63 | + /// { -- Start |
| 64 | + /// { |
| 65 | + /// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. |
| 66 | + /// } -- Found |
| 67 | + /// ``` |
| 68 | + /// - Parameters: |
| 69 | + /// - open: The opening pair to look for. |
| 70 | + /// - close: The closing pair to look for. |
| 71 | + /// - from: The index to start from. This should not include the start character. Eg given `"{ }"` looking forward |
| 72 | + /// the index should be `1` |
| 73 | + /// - limit: A limiting index to stop at. When `reverse` is `true`, this is the minimum index. When `false` this |
| 74 | + /// is the maximum index. |
| 75 | + /// - reverse: Set to `true` to walk backwards from `from`. |
| 76 | + /// - Returns: The index of the found closing pair, if any. |
| 77 | + internal func findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int? { |
| 78 | + // Walk the text, counting each close. When we find an open that makes closeCount < 0, return that index. |
| 79 | + var options: NSString.EnumerationOptions = .byCaretPositions |
| 80 | + if reverse { |
| 81 | + options = options.union(.reverse) |
| 82 | + } |
| 83 | + var closeCount = 0 |
| 84 | + var index: Int? |
| 85 | + textView.textContentStorage?.textStorage?.mutableString.enumerateSubstrings( |
| 86 | + in: reverse ? |
| 87 | + NSRange(location: limit, length: from - limit) : |
| 88 | + NSRange(location: from, length: limit - from), |
| 89 | + options: options, |
| 90 | + using: { substring, range, _, stop in |
| 91 | + if substring == close { |
| 92 | + closeCount += 1 |
| 93 | + } else if substring == open { |
| 94 | + closeCount -= 1 |
| 95 | + } |
| 96 | + |
| 97 | + if closeCount < 0 { |
| 98 | + index = range.location |
| 99 | + stop.pointee = true |
| 100 | + } |
| 101 | + } |
| 102 | + ) |
| 103 | + return index |
| 104 | + } |
| 105 | + |
| 106 | + /// Adds a temporary highlight effect to the given range. |
| 107 | + /// - Parameters: |
| 108 | + /// - range: The range to highlight |
| 109 | + /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. |
| 110 | + private func highlightRange(_ range: NSTextRange, scrollToRange: Bool = false) { |
| 111 | + guard let bracketPairHighlight = bracketPairHighlight, |
| 112 | + var rectToHighlight = textView.textLayoutManager.textSelectionSegmentFrame( |
| 113 | + in: range, type: .highlight |
| 114 | + ) else { |
| 115 | + return |
| 116 | + } |
| 117 | + let layer = CAShapeLayer() |
| 118 | + |
| 119 | + switch bracketPairHighlight { |
| 120 | + case .flash: |
| 121 | + rectToHighlight.size.width += 4 |
| 122 | + rectToHighlight.origin.x -= 2 |
| 123 | + |
| 124 | + layer.cornerRadius = 3.0 |
| 125 | + layer.backgroundColor = NSColor(hex: 0xFEFA80, alpha: 1.0).cgColor |
| 126 | + layer.shadowColor = .black |
| 127 | + layer.shadowOpacity = 0.3 |
| 128 | + layer.shadowOffset = CGSize(width: 0, height: 1) |
| 129 | + layer.shadowRadius = 3.0 |
| 130 | + layer.opacity = 0.0 |
| 131 | + case .bordered(let borderColor): |
| 132 | + layer.borderColor = borderColor.cgColor |
| 133 | + layer.cornerRadius = 2.5 |
| 134 | + layer.borderWidth = 0.5 |
| 135 | + layer.opacity = 1.0 |
| 136 | + case .underline(let underlineColor): |
| 137 | + layer.lineWidth = 1.0 |
| 138 | + layer.lineCap = .round |
| 139 | + layer.strokeColor = underlineColor.cgColor |
| 140 | + layer.opacity = 1.0 |
| 141 | + } |
| 142 | + |
| 143 | + switch bracketPairHighlight { |
| 144 | + case .flash, .bordered: |
| 145 | + layer.frame = rectToHighlight |
| 146 | + case .underline: |
| 147 | + let path = CGMutablePath() |
| 148 | + let pathY = rectToHighlight.maxY - (lineHeight - font.lineHeight)/4 |
| 149 | + path.move(to: CGPoint(x: rectToHighlight.minX, y: pathY)) |
| 150 | + path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: pathY)) |
| 151 | + layer.path = path |
| 152 | + } |
| 153 | + |
| 154 | + // Insert above selection but below text |
| 155 | + textView.layer?.insertSublayer(layer, at: 1) |
| 156 | + |
| 157 | + if bracketPairHighlight == .flash { |
| 158 | + addFlashAnimation(to: layer, rectToHighlight: rectToHighlight) |
| 159 | + } |
| 160 | + |
| 161 | + highlightLayers.append(layer) |
| 162 | + |
| 163 | + // Scroll the last rect into view, makes a small assumption that the last rect is the lowest visually. |
| 164 | + if scrollToRange { |
| 165 | + textView.scrollToVisible(rectToHighlight) |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + /// Adds a flash animation to the given layer. |
| 170 | + /// - Parameters: |
| 171 | + /// - layer: The layer to add the animation to. |
| 172 | + /// - rectToHighlight: The layer's bounding rect to animate. |
| 173 | + private func addFlashAnimation(to layer: CALayer, rectToHighlight: CGRect) { |
| 174 | + CATransaction.begin() |
| 175 | + CATransaction.setCompletionBlock { [weak self] in |
| 176 | + if let index = self?.highlightLayers.firstIndex(of: layer) { |
| 177 | + self?.highlightLayers.remove(at: index) |
| 178 | + } |
| 179 | + layer.removeFromSuperlayer() |
| 180 | + } |
| 181 | + let duration = 0.75 |
| 182 | + let group = CAAnimationGroup() |
| 183 | + group.duration = duration |
| 184 | + |
| 185 | + let opacityAnim = CAKeyframeAnimation(keyPath: "opacity") |
| 186 | + opacityAnim.duration = duration |
| 187 | + opacityAnim.values = [1.0, 1.0, 0.0] |
| 188 | + opacityAnim.keyTimes = [0.1, 0.8, 0.9] |
| 189 | + |
| 190 | + let positionAnim = CAKeyframeAnimation(keyPath: "position") |
| 191 | + positionAnim.keyTimes = [0.0, 0.05, 0.1] |
| 192 | + positionAnim.values = [ |
| 193 | + NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y), |
| 194 | + NSPoint(x: rectToHighlight.origin.x - 2, y: rectToHighlight.origin.y - 2), |
| 195 | + NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y) |
| 196 | + ] |
| 197 | + positionAnim.duration = duration |
| 198 | + |
| 199 | + var betweenSize = rectToHighlight |
| 200 | + betweenSize.size.width += 4 |
| 201 | + betweenSize.size.height += 4 |
| 202 | + let boundsAnim = CAKeyframeAnimation(keyPath: "bounds") |
| 203 | + boundsAnim.keyTimes = [0.0, 0.05, 0.1] |
| 204 | + boundsAnim.values = [rectToHighlight, betweenSize, rectToHighlight] |
| 205 | + boundsAnim.duration = duration |
| 206 | + |
| 207 | + group.animations = [opacityAnim, boundsAnim] |
| 208 | + layer.add(group, forKey: nil) |
| 209 | + CATransaction.commit() |
| 210 | + } |
| 211 | + |
| 212 | + /// Adds a temporary highlight effect to the given range. |
| 213 | + /// - Parameters: |
| 214 | + /// - range: The range to highlight |
| 215 | + /// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. |
| 216 | + public func highlightRange(_ range: NSRange, scrollToRange: Bool = false) { |
| 217 | + guard let textRange = NSTextRange(range, provider: textView.textContentManager) else { |
| 218 | + return |
| 219 | + } |
| 220 | + |
| 221 | + highlightRange(textRange, scrollToRange: scrollToRange) |
| 222 | + } |
| 223 | + |
| 224 | + /// Safely removes all highlight layers. |
| 225 | + internal func removeHighlightLayers() { |
| 226 | + highlightLayers.forEach { layer in |
| 227 | + layer.removeFromSuperlayer() |
| 228 | + } |
| 229 | + highlightLayers.removeAll() |
| 230 | + } |
| 231 | +} |
0 commit comments