-
Notifications
You must be signed in to change notification settings - Fork 85
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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">
- Loading branch information
1 parent
045bd35
commit b60e0fc
Showing
9 changed files
with
537 additions
and
146 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
231 changes: 231 additions & 0 deletions
231
Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
// | ||
// STTextViewController+HighlightRange.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Khan Winter on 4/26/23. | ||
// | ||
|
||
import AppKit | ||
import STTextView | ||
|
||
extension STTextViewController { | ||
/// Highlights bracket pairs using the current selection. | ||
internal func highlightSelectionPairs() { | ||
guard bracketPairHighlight != nil else { return } | ||
removeHighlightLayers() | ||
for selection in textView.textLayoutManager.textSelections.flatMap(\.textRanges) { | ||
if selection.isEmpty, | ||
let range = selection.nsRange(using: textView.textContentManager), | ||
range.location > 0, // Range is not the beginning of the document | ||
let preceedingCharacter = textView.textContentStorage?.textStorage?.substring( | ||
from: NSRange(location: range.location - 1, length: 1) // The preceeding character exists | ||
) { | ||
for pair in BracketPairs.allValues { | ||
if preceedingCharacter == pair.0 { | ||
// Walk forwards | ||
if let characterIndex = findClosingPair( | ||
pair.0, | ||
pair.1, | ||
from: range.location, | ||
limit: min(NSMaxRange(textView.visibleTextRange ?? .zero) + 4096, | ||
NSMaxRange(textView.documentRange)), | ||
reverse: false | ||
) { | ||
highlightRange(NSRange(location: characterIndex, length: 1)) | ||
if bracketPairHighlight?.highlightsSourceBracket ?? false { | ||
highlightRange(NSRange(location: range.location - 1, length: 1)) | ||
} | ||
} | ||
} else if preceedingCharacter == pair.1 && range.location - 1 > 0 { | ||
// Walk backwards | ||
if let characterIndex = findClosingPair( | ||
pair.1, | ||
pair.0, | ||
from: range.location - 1, | ||
limit: max((textView.visibleTextRange?.location ?? 0) - 4096, | ||
textView.documentRange.location), | ||
reverse: true | ||
) { | ||
highlightRange(NSRange(location: characterIndex, length: 1)) | ||
if bracketPairHighlight?.highlightsSourceBracket ?? false { | ||
highlightRange(NSRange(location: range.location - 1, length: 1)) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// Finds a closing character given a pair of characters, ignores pairs inside the given pair. | ||
/// | ||
/// ```pseudocode | ||
/// { -- Start | ||
/// { | ||
/// } -- A naive algorithm may find this character as the closing pair, which would be incorrect. | ||
/// } -- Found | ||
/// ``` | ||
/// - Parameters: | ||
/// - open: The opening pair to look for. | ||
/// - close: The closing pair to look for. | ||
/// - from: The index to start from. This should not include the start character. Eg given `"{ }"` looking forward | ||
/// the index should be `1` | ||
/// - limit: A limiting index to stop at. When `reverse` is `true`, this is the minimum index. When `false` this | ||
/// is the maximum index. | ||
/// - reverse: Set to `true` to walk backwards from `from`. | ||
/// - Returns: The index of the found closing pair, if any. | ||
internal func findClosingPair(_ close: String, _ open: String, from: Int, limit: Int, reverse: Bool) -> Int? { | ||
// Walk the text, counting each close. When we find an open that makes closeCount < 0, return that index. | ||
var options: NSString.EnumerationOptions = .byCaretPositions | ||
if reverse { | ||
options = options.union(.reverse) | ||
} | ||
var closeCount = 0 | ||
var index: Int? | ||
textView.textContentStorage?.textStorage?.mutableString.enumerateSubstrings( | ||
in: reverse ? | ||
NSRange(location: limit, length: from - limit) : | ||
NSRange(location: from, length: limit - from), | ||
options: options, | ||
using: { substring, range, _, stop in | ||
if substring == close { | ||
closeCount += 1 | ||
} else if substring == open { | ||
closeCount -= 1 | ||
} | ||
|
||
if closeCount < 0 { | ||
index = range.location | ||
stop.pointee = true | ||
} | ||
} | ||
) | ||
return index | ||
} | ||
|
||
/// Adds a temporary highlight effect to the given range. | ||
/// - Parameters: | ||
/// - range: The range to highlight | ||
/// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. | ||
private func highlightRange(_ range: NSTextRange, scrollToRange: Bool = false) { | ||
guard let bracketPairHighlight = bracketPairHighlight, | ||
var rectToHighlight = textView.textLayoutManager.textSelectionSegmentFrame( | ||
in: range, type: .highlight | ||
) else { | ||
return | ||
} | ||
let layer = CAShapeLayer() | ||
|
||
switch bracketPairHighlight { | ||
case .flash: | ||
rectToHighlight.size.width += 4 | ||
rectToHighlight.origin.x -= 2 | ||
|
||
layer.cornerRadius = 3.0 | ||
layer.backgroundColor = NSColor(hex: 0xFEFA80, alpha: 1.0).cgColor | ||
layer.shadowColor = .black | ||
layer.shadowOpacity = 0.3 | ||
layer.shadowOffset = CGSize(width: 0, height: 1) | ||
layer.shadowRadius = 3.0 | ||
layer.opacity = 0.0 | ||
case .bordered(let borderColor): | ||
layer.borderColor = borderColor.cgColor | ||
layer.cornerRadius = 2.5 | ||
layer.borderWidth = 0.5 | ||
layer.opacity = 1.0 | ||
case .underline(let underlineColor): | ||
layer.lineWidth = 1.0 | ||
layer.lineCap = .round | ||
layer.strokeColor = underlineColor.cgColor | ||
layer.opacity = 1.0 | ||
} | ||
|
||
switch bracketPairHighlight { | ||
case .flash, .bordered: | ||
layer.frame = rectToHighlight | ||
case .underline: | ||
let path = CGMutablePath() | ||
let pathY = rectToHighlight.maxY - (lineHeight - font.lineHeight)/4 | ||
path.move(to: CGPoint(x: rectToHighlight.minX, y: pathY)) | ||
path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: pathY)) | ||
layer.path = path | ||
} | ||
|
||
// Insert above selection but below text | ||
textView.layer?.insertSublayer(layer, at: 1) | ||
|
||
if bracketPairHighlight == .flash { | ||
addFlashAnimation(to: layer, rectToHighlight: rectToHighlight) | ||
} | ||
|
||
highlightLayers.append(layer) | ||
|
||
// Scroll the last rect into view, makes a small assumption that the last rect is the lowest visually. | ||
if scrollToRange { | ||
textView.scrollToVisible(rectToHighlight) | ||
} | ||
} | ||
|
||
/// Adds a flash animation to the given layer. | ||
/// - Parameters: | ||
/// - layer: The layer to add the animation to. | ||
/// - rectToHighlight: The layer's bounding rect to animate. | ||
private func addFlashAnimation(to layer: CALayer, rectToHighlight: CGRect) { | ||
CATransaction.begin() | ||
CATransaction.setCompletionBlock { [weak self] in | ||
if let index = self?.highlightLayers.firstIndex(of: layer) { | ||
self?.highlightLayers.remove(at: index) | ||
} | ||
layer.removeFromSuperlayer() | ||
} | ||
let duration = 0.75 | ||
let group = CAAnimationGroup() | ||
group.duration = duration | ||
|
||
let opacityAnim = CAKeyframeAnimation(keyPath: "opacity") | ||
opacityAnim.duration = duration | ||
opacityAnim.values = [1.0, 1.0, 0.0] | ||
opacityAnim.keyTimes = [0.1, 0.8, 0.9] | ||
|
||
let positionAnim = CAKeyframeAnimation(keyPath: "position") | ||
positionAnim.keyTimes = [0.0, 0.05, 0.1] | ||
positionAnim.values = [ | ||
NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y), | ||
NSPoint(x: rectToHighlight.origin.x - 2, y: rectToHighlight.origin.y - 2), | ||
NSPoint(x: rectToHighlight.origin.x, y: rectToHighlight.origin.y) | ||
] | ||
positionAnim.duration = duration | ||
|
||
var betweenSize = rectToHighlight | ||
betweenSize.size.width += 4 | ||
betweenSize.size.height += 4 | ||
let boundsAnim = CAKeyframeAnimation(keyPath: "bounds") | ||
boundsAnim.keyTimes = [0.0, 0.05, 0.1] | ||
boundsAnim.values = [rectToHighlight, betweenSize, rectToHighlight] | ||
boundsAnim.duration = duration | ||
|
||
group.animations = [opacityAnim, boundsAnim] | ||
layer.add(group, forKey: nil) | ||
CATransaction.commit() | ||
} | ||
|
||
/// Adds a temporary highlight effect to the given range. | ||
/// - Parameters: | ||
/// - range: The range to highlight | ||
/// - scrollToRange: Set to true to scroll to the given range when highlighting. Defaults to `false`. | ||
public func highlightRange(_ range: NSRange, scrollToRange: Bool = false) { | ||
guard let textRange = NSTextRange(range, provider: textView.textContentManager) else { | ||
return | ||
} | ||
|
||
highlightRange(textRange, scrollToRange: scrollToRange) | ||
} | ||
|
||
/// Safely removes all highlight layers. | ||
internal func removeHighlightLayers() { | ||
highlightLayers.forEach { layer in | ||
layer.removeFromSuperlayer() | ||
} | ||
highlightLayers.removeAll() | ||
} | ||
} |
Oops, something went wrong.