Skip to content

Commit

Permalink
Implement Bracket Pair Highlighting (#186)
Browse files Browse the repository at this point in the history
### 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
thecoolwinter authored May 8, 2023
1 parent 045bd35 commit b60e0fc
Show file tree
Hide file tree
Showing 9 changed files with 537 additions and 146 deletions.
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

0 comments on commit b60e0fc

Please sign in to comment.