-
Notifications
You must be signed in to change notification settings - Fork 100
Implement Bracket Pair Highlighting #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
thecoolwinter
merged 7 commits into
CodeEditApp:main
from
thecoolwinter:feat/brace-pair-highlight
May 8, 2023
Merged
Changes from 3 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
dc80f9b
Add brace pair highlighting
thecoolwinter f675fbc
Make `highlightRange(...)` private
thecoolwinter 8a27dd8
Pin `STTextView`
thecoolwinter f8b4b26
Rename to `bracket`, add `.underline(color:)`
thecoolwinter 79098f0
Add `underline(color:)` test
thecoolwinter 3ebaf9e
Adjust underline Y value
thecoolwinter 59fe2b9
Fix flaky test, `removeHighlightLayers`
thecoolwinter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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
215 changes: 215 additions & 0 deletions
215
Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift
This file contains hidden or 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,215 @@ | ||
// | ||
// STTextViewController+HighlightRange.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Khan Winter on 4/26/23. | ||
// | ||
|
||
import AppKit | ||
import STTextView | ||
|
||
extension STTextViewController { | ||
/// Highlights brace pairs using the current selection. | ||
internal func highlightSelectionPairs() { | ||
guard bracePairHighlight != nil else { return } | ||
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 BracePairs.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 bracePairHighlight == .box { | ||
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 bracePairHighlight == .box { | ||
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 bracePairHighlight = bracePairHighlight, | ||
var rectToHighlight = textView.textLayoutManager.textSelectionSegmentFrame( | ||
in: range, type: .highlight | ||
) else { | ||
return | ||
} | ||
let layer = CAShapeLayer() | ||
|
||
switch bracePairHighlight { | ||
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 .box: | ||
layer.borderColor = theme.text.cgColor | ||
layer.borderWidth = 0.5 | ||
layer.opacity = 0.5 | ||
} | ||
|
||
layer.frame = rectToHighlight | ||
|
||
// Insert above selection but below text | ||
textView.layer?.insertSublayer(layer, at: 1) | ||
|
||
if bracePairHighlight == .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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.