Skip to content

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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ let package = Package(
dependencies: [
.package(
url: "https://github.com/krzyzanowskim/STTextView.git",
from: "0.5.3"
exact: "0.5.3"
),
.package(
url: "https://github.com/CodeEditApp/CodeEditLanguages.git",
Expand Down
14 changes: 11 additions & 3 deletions Sources/CodeEditTextView/CodeEditTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
/// - isEditable: A Boolean value that controls whether the text view allows the user to edit text.
/// - letterSpacing: The amount of space to use between letters, as a percent. Eg: `1.0` = no space, `1.5` = 1/2 a
/// character's width between characters, etc. Defaults to `1.0`
/// - bracketPairHighlight: The type of highlight to use to highlight bracket pairs.
/// See `BracketPairHighlight` for more information. Defaults to `nil`
public init(
_ text: Binding<String>,
language: CodeLanguage,
Expand All @@ -48,7 +50,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
highlightProvider: HighlightProviding? = nil,
contentInsets: NSEdgeInsets? = nil,
isEditable: Bool = true,
letterSpacing: Double = 1.0
letterSpacing: Double = 1.0,
bracketPairHighlight: BracketPairHighlight? = nil
) {
self._text = text
self.language = language
Expand All @@ -65,6 +68,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
self.contentInsets = contentInsets
self.isEditable = isEditable
self.letterSpacing = letterSpacing
self.bracketPairHighlight = bracketPairHighlight
}

@Binding private var text: String
Expand All @@ -82,6 +86,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
private var contentInsets: NSEdgeInsets?
private var isEditable: Bool
private var letterSpacing: Double
private var bracketPairHighlight: BracketPairHighlight?

public typealias NSViewControllerType = STTextViewController

Expand All @@ -101,7 +106,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
highlightProvider: highlightProvider,
contentInsets: contentInsets,
isEditable: isEditable,
letterSpacing: letterSpacing
letterSpacing: letterSpacing,
bracketPairHighlight: bracketPairHighlight
)
return controller
}
Expand All @@ -119,6 +125,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
controller.lineHeightMultiple = lineHeight
controller.editorOverscroll = editorOverscroll
controller.contentInsets = contentInsets
controller.bracketPairHighlight = bracketPairHighlight

// Updating the language, theme, tab width and indent option needlessly can cause highlights to be re-calculated
if controller.language.id != language.id {
Expand Down Expand Up @@ -152,6 +159,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable {
controller.theme == theme &&
controller.indentOption == indentOption &&
controller.tabWidth == tabWidth &&
controller.letterSpacing == letterSpacing
controller.letterSpacing == letterSpacing &&
controller.bracketPairHighlight == bracketPairHighlight
}
}
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()
}
}
Loading