Skip to content

Commit b60e0fc

Browse files
Implement Bracket Pair Highlighting (#186)
### Description Implements bracket/pair highlighting as described in #67. Adds a few functions to `STTextViewController`: - `highlightSelectionPairs()` - Highlights selection pairs from the current selection. This is called whenever the selection is updated and handles determining whether or not the selection highlight should be applied. It makes use of `findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int?` to determine the indices of the opening and closing pairs. - `highlightRange(_ range: NSTextRange, scrollToRange: Bool = false)` - Applies a highlight to the given range, determined by the `bracketPairHighlight` property. Also handles removing animated layers if needed (as in the case of the `flash` highlight type). There are two highlight types: - Flash: Flashes a yellow rectangle below the given range with an animation. The highlight disappears after 0.75s. This is modeled closely to the Xcode version. - Bordered: Adds a border around both the opening and closing bracket pair. These borders only disappear when the selection changes. - Underline: Adds an underline to both the opening and closing bracket pair. These borders only disappear when the selection changes. All highlight types are documented in the `BracketPairHighlight` enum. Highlighted pairs are the same set of pairs used for the pair autocomplete filter: - `{` `}` - `[` `]` - `<` `>` - `(` `)` This feature can also be disabled by setting the `bracketPairHighlight` property on `CodeEditTextView` to `nil`. ### Related Issues * closes #67 ### Checklist <!--- Add things that are not yet implemented above --> - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Box highlight: <img width="210" alt="Screenshot 2023-05-07 at 8 07 11 PM" src="https://user-images.githubusercontent.com/35942988/236713451-d74edbbe-b41c-45c4-98e2-bfcef9e84fd6.png"> Flash highlight: https://user-images.githubusercontent.com/35942988/236056573-ce5f8e61-5ed5-4799-a054-0591cfc7653b.mov Underline highlight with red color: <img width="250" alt="Screenshot 2023-05-08 at 2 15 27 PM" src="https://user-images.githubusercontent.com/35942988/236912499-68665a38-34d2-44fd-b62d-c7ef2f130c7e.png">
1 parent 045bd35 commit b60e0fc

File tree

9 files changed

+537
-146
lines changed

9 files changed

+537
-146
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ let package = Package(
1515
dependencies: [
1616
.package(
1717
url: "https://github.com/krzyzanowskim/STTextView.git",
18-
from: "0.5.3"
18+
exact: "0.5.3"
1919
),
2020
.package(
2121
url: "https://github.com/CodeEditApp/CodeEditLanguages.git",

Sources/CodeEditTextView/CodeEditTextView.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
3333
/// - isEditable: A Boolean value that controls whether the text view allows the user to edit text.
3434
/// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a
3535
/// character's width between characters, etc. Defaults to `1.0`
36+
/// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs.
37+
/// See `BracketPairHighlight` for more information. Defaults to `nil`
3638
public init(
3739
_ text: Binding<String>,
3840
language: CodeLanguage,
@@ -48,7 +50,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
4850
highlightProvider: HighlightProviding? = nil,
4951
contentInsets: NSEdgeInsets? = nil,
5052
isEditable: Bool = true,
51-
letterSpacing: Double = 1.0
53+
letterSpacing: Double = 1.0,
54+
bracketPairHighlight: BracketPairHighlight? = nil
5255
) {
5356
self._text = text
5457
self.language = language
@@ -65,6 +68,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
6568
self.contentInsets = contentInsets
6669
self.isEditable = isEditable
6770
self.letterSpacing = letterSpacing
71+
self.bracketPairHighlight = bracketPairHighlight
6872
}
6973

7074
@Binding private var text: String
@@ -82,6 +86,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
8286
private var contentInsets: NSEdgeInsets?
8387
private var isEditable: Bool
8488
private var letterSpacing: Double
89+
private var bracketPairHighlight: BracketPairHighlight?
8590

8691
public typealias NSViewControllerType = STTextViewController
8792

@@ -101,7 +106,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
101106
highlightProvider: highlightProvider,
102107
contentInsets: contentInsets,
103108
isEditable: isEditable,
104-
letterSpacing: letterSpacing
109+
letterSpacing: letterSpacing,
110+
bracketPairHighlight: bracketPairHighlight
105111
)
106112
return controller
107113
}
@@ -119,6 +125,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
119125
controller.lineHeightMultiple = lineHeight
120126
controller.editorOverscroll = editorOverscroll
121127
controller.contentInsets = contentInsets
128+
controller.bracketPairHighlight = bracketPairHighlight
122129

123130
// Updating the language, theme, tab width and indent option needlessly can cause highlights to be re-calculated
124131
if controller.language.id != language.id {
@@ -152,6 +159,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
152159
controller.theme == theme &&
153160
controller.indentOption == indentOption &&
154161
controller.tabWidth == tabWidth &&
155-
controller.letterSpacing == letterSpacing
162+
controller.letterSpacing == letterSpacing &&
163+
controller.bracketPairHighlight == bracketPairHighlight
156164
}
157165
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

Comments
 (0)