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 3 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`
/// - bracePairHighlight: The type of highlight to use to highlight brace pairs.
/// See `BracePairHighlight` for more information.
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,
bracePairHighlight: BracePairHighlight? = 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.bracePairHighlight = bracePairHighlight
}

@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 bracePairHighlight: BracePairHighlight?

public typealias NSViewControllerType = STTextViewController

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

// 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.bracePairHighlight == bracePairHighlight
}
}
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()
}
}
Loading