diff --git a/Package.swift b/Package.swift index 6fd9c8ac5..244bfd4ad 100644 --- a/Package.swift +++ b/Package.swift @@ -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", diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 4dc39dd74..a0ac4390e 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -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, language: CodeLanguage, @@ -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 @@ -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 @@ -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 @@ -101,7 +106,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { highlightProvider: highlightProvider, contentInsets: contentInsets, isEditable: isEditable, - letterSpacing: letterSpacing + letterSpacing: letterSpacing, + bracketPairHighlight: bracketPairHighlight ) return controller } @@ -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 { @@ -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 } } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift new file mode 100644 index 000000000..848b6f15d --- /dev/null +++ b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift @@ -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() + } +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift new file mode 100644 index 000000000..8cb55c110 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift @@ -0,0 +1,145 @@ +// +// STTextViewController+Lifecycle.swift +// CodeEditTextView +// +// Created by Khan Winter on 5/3/23. +// + +import AppKit +import STTextView + +extension STTextViewController { + // swiftlint:disable:next function_body_length + public override func loadView() { + textView = STTextView() + + let scrollView = CEScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true + scrollView.documentView = textView + scrollView.drawsBackground = useThemeBackground + scrollView.automaticallyAdjustsContentInsets = contentInsets == nil + if let contentInsets = contentInsets { + scrollView.contentInsets = contentInsets + } + + rulerView = STLineNumberRulerView(textView: textView, scrollView: scrollView) + rulerView.backgroundColor = useThemeBackground ? theme.background : .clear + rulerView.textColor = .secondaryLabelColor + rulerView.drawSeparator = false + rulerView.baselineOffset = baselineOffset + rulerView.font = rulerFont + rulerView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua + ? NSColor.quaternaryLabelColor + : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + rulerView.rulerInsets = STRulerInsets(leading: rulerFont.pointSize * 1.6, trailing: 8) + rulerView.allowsMarkers = false + + if self.isEditable == false { + rulerView.selectedLineTextColor = nil + rulerView.selectedLineHighlightColor = theme.background + } + + scrollView.verticalRulerView = rulerView + scrollView.rulersVisible = true + + textView.typingAttributes = attributesFor(nil) + textView.defaultParagraphStyle = self.paragraphStyle + textView.font = self.font + textView.textColor = theme.text + textView.backgroundColor = useThemeBackground ? theme.background : .clear + textView.insertionPointColor = theme.insertionPoint + textView.insertionPointWidth = 1.0 + textView.selectionBackgroundColor = theme.selection + textView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua + ? NSColor.quaternaryLabelColor + : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) + textView.string = self.text.wrappedValue + textView.isEditable = self.isEditable + textView.highlightSelectedLine = true + textView.allowsUndo = true + textView.setupMenus() + textView.delegate = self + textView.highlightSelectedLine = self.isEditable + + scrollView.documentView = textView + + scrollView.translatesAutoresizingMaskIntoConstraints = false + + self.view = scrollView + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + self.keyDown(with: event) + return event + } + + setUpHighlighter() + setHighlightProvider(self.highlightProvider) + setUpTextFormation() + + self.setCursorPosition(self.cursorPosition.wrappedValue) + } + + public override func viewDidLoad() { + super.viewDidLoad() + + NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, + object: nil, + queue: .main) { [weak self] _ in + guard let self = self else { return } + (self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets + self.updateTextContainerWidthIfNeeded() + } + + NotificationCenter.default.addObserver( + forName: STTextView.didChangeSelectionNotification, + object: nil, + queue: .main + ) { [weak self] _ in + let textSelections = self?.textView.textLayoutManager.textSelections.flatMap(\.textRanges) + guard self?.lastTextSelections != textSelections else { + return + } + self?.lastTextSelections = textSelections ?? [] + + self?.updateCursorPosition() + self?.highlightSelectionPairs() + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: (self.view as? NSScrollView)?.verticalRulerView, + queue: .main + ) { [weak self] _ in + self?.updateTextContainerWidthIfNeeded() + if self?.bracketPairHighlight == .flash { + self?.removeHighlightLayers() + } + } + + systemAppearance = NSApp.effectiveAppearance.name + + NSApp.publisher(for: \.effectiveAppearance) + .receive(on: RunLoop.main) + .sink { [weak self] newValue in + guard let self = self else { return } + + if self.systemAppearance != newValue.name { + self.systemAppearance = newValue.name + } + } + .store(in: &cancellables) + } + + public override func viewWillAppear() { + super.viewWillAppear() + updateTextContainerWidthIfNeeded() + } +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index f5543a82d..3d7915cda 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -19,6 +19,13 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt internal var rulerView: STLineNumberRulerView! + /// Internal reference to any injected layers in the text view. + internal var highlightLayers: [CALayer] = [] + + /// Tracks the last text selections. Used to debounce `STTextView.didChangeSelectionNotification` being sent twice + /// for every new selection. + internal var lastTextSelections: [NSTextRange] = [] + /// Binding for the `textView`s string public var text: Binding @@ -88,6 +95,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } } + /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. + public var bracketPairHighlight: BracketPairHighlight? + /// The kern to use for characters. Defaults to `0.0` and is updated when `letterSpacing` is set. internal var kern: CGFloat = 0.0 @@ -119,7 +129,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt highlightProvider: HighlightProviding? = nil, contentInsets: NSEdgeInsets? = nil, isEditable: Bool, - letterSpacing: Double + letterSpacing: Double, + bracketPairHighlight: BracketPairHighlight? = nil ) { self.text = text self.language = language @@ -135,6 +146,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable + self.bracketPairHighlight = bracketPairHighlight super.init(nibName: nil, bundle: nil) } @@ -142,132 +154,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt fatalError() } - // MARK: VC Lifecycle - - // swiftlint:disable:next function_body_length - public override func loadView() { - textView = STTextView() - - let scrollView = CEScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.hasVerticalScroller = true - scrollView.documentView = textView - scrollView.drawsBackground = useThemeBackground - scrollView.automaticallyAdjustsContentInsets = contentInsets == nil - if let contentInsets = contentInsets { - scrollView.contentInsets = contentInsets - } - - rulerView = STLineNumberRulerView(textView: textView, scrollView: scrollView) - rulerView.backgroundColor = useThemeBackground ? theme.background : .clear - rulerView.textColor = .secondaryLabelColor - rulerView.drawSeparator = false - rulerView.baselineOffset = baselineOffset - rulerView.font = rulerFont - rulerView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - rulerView.rulerInsets = STRulerInsets(leading: rulerFont.pointSize * 1.6, trailing: 8) - rulerView.allowsMarkers = false - - if self.isEditable == false { - rulerView.selectedLineTextColor = nil - rulerView.selectedLineHighlightColor = theme.background - } - - scrollView.verticalRulerView = rulerView - scrollView.rulersVisible = true - - textView.typingAttributes = attributesFor(nil) - textView.defaultParagraphStyle = self.paragraphStyle - textView.font = self.font - textView.textColor = theme.text - textView.backgroundColor = useThemeBackground ? theme.background : .clear - textView.insertionPointColor = theme.insertionPoint - textView.insertionPointWidth = 1.0 - textView.selectionBackgroundColor = theme.selection - textView.selectedLineHighlightColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua - ? NSColor.quaternaryLabelColor - : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) - textView.string = self.text.wrappedValue - textView.isEditable = self.isEditable - textView.highlightSelectedLine = true - textView.allowsUndo = true - textView.setupMenus() - textView.delegate = self - textView.highlightSelectedLine = self.isEditable - - scrollView.documentView = textView - - scrollView.translatesAutoresizingMaskIntoConstraints = false - - self.view = scrollView - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - self.keyDown(with: event) - return event - } - - setUpHighlighter() - setHighlightProvider(self.highlightProvider) - setUpTextFormation() - - self.setCursorPosition(self.cursorPosition.wrappedValue) - } - - public override func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, - object: nil, - queue: .main) { [weak self] _ in - guard let self = self else { return } - (self.view as? NSScrollView)?.contentView.contentInsets.bottom = self.bottomContentInsets - self.updateTextContainerWidthIfNeeded() - } - - NotificationCenter.default.addObserver( - forName: STTextView.didChangeSelectionNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.updateCursorPosition() - } - - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: (self.view as? NSScrollView)?.verticalRulerView, - queue: .main - ) { [weak self] _ in - self?.updateTextContainerWidthIfNeeded() - } - - systemAppearance = NSApp.effectiveAppearance.name - - NSApp.publisher(for: \.effectiveAppearance) - .receive(on: RunLoop.main) - .sink { [weak self] newValue in - guard let self = self else { return } - - if self.systemAppearance != newValue.name { - self.systemAppearance = newValue.name - } - } - .store(in: &cancellables) - } - - public override func viewWillAppear() { - super.viewWillAppear() - updateTextContainerWidthIfNeeded() - } - public func textViewDidChangeText(_ notification: Notification) { self.text.wrappedValue = textView.string } @@ -288,7 +174,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } /// ScrollView's bottom inset using as editor overscroll - private var bottomContentInsets: CGFloat { + internal var bottomContentInsets: CGFloat { let height = view.frame.height var inset = editorOverscroll * height @@ -335,6 +221,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt highlighter?.invalidate() updateTextContainerWidthIfNeeded() + highlightSelectionPairs() } /// Calculated line height depending on ``STTextViewController/lineHeightMultiple`` @@ -350,7 +237,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt // MARK: Selectors override public func keyDown(with event: NSEvent) { - // This should be uneccessary but if removed STTextView receives some `keydown`s twice. + if bracketPairHighlight == .flash { + removeHighlightLayers() + } } public override func insertTab(_ sender: Any?) { @@ -358,6 +247,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } deinit { + removeHighlightLayers() textView = nil highlighter = nil cancellables.forEach { $0.cancel() } diff --git a/Sources/CodeEditTextView/Enums/BracketPairHighlight.swift b/Sources/CodeEditTextView/Enums/BracketPairHighlight.swift new file mode 100644 index 000000000..026abf539 --- /dev/null +++ b/Sources/CodeEditTextView/Enums/BracketPairHighlight.swift @@ -0,0 +1,45 @@ +// +// BracketPairHighlight.swift +// CodeEditTextView +// +// Created by Khan Winter on 5/3/23. +// + +import AppKit + +/// An enum representing the type of highlight to use for bracket pairs. +public enum BracketPairHighlight: Equatable { + /// Highlight both the opening and closing character in a pair with a bounding box. + /// The boxes will stay on screen until the cursor moves away from the bracket pair. + case bordered(color: NSColor) + /// Flash a yellow highlight box on only the opposite character in the pair. + /// This is closely matched to Xcode's flash highlight for bracket pairs, and animates in and out over the course + /// of `0.75` seconds. + case flash + /// Highlight both the opening and closing character in a pair with an underline. + /// The underline will stay on screen until the cursor moves away from the bracket pair. + case underline(color: NSColor) + + public static func == (lhs: BracketPairHighlight, rhs: BracketPairHighlight) -> Bool { + switch (lhs, rhs) { + case (.flash, .flash): + return true + case (.bordered(let lhsColor), .bordered(let rhsColor)): + return lhsColor == rhsColor + case (.underline(let lhsColor), .underline(let rhsColor)): + return lhsColor == rhsColor + default: + return false + } + } + + /// Returns `true` if the highlight should act on both the opening and closing bracket. + var highlightsSourceBracket: Bool { + switch self { + case .bordered, .underline: + return true + case .flash: + return false + } + } +} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 202e00181..5e24773ed 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -12,6 +12,15 @@ import TextStory extension STTextViewController { + internal enum BracketPairs { + static let allValues: [(String, String)] = [ + ("{", "}"), + ("[", "]"), + ("(", ")"), + ("<", ">") + ] + } + // MARK: - Filter Configuration /// Initializes any filters for text editing. @@ -20,13 +29,6 @@ extension STTextViewController { let indentationUnit = indentOption.stringValue - let pairsToHandle: [(String, String)] = [ - ("{", "}"), - ("[", "]"), - ("(", ")"), - ("<", ">") - ] - let indenter: TextualIndenter = getTextIndenter() let whitespaceProvider = WhitespaceProviders( leadingWhitespace: indenter.substitionProvider(indentationUnit: indentationUnit, @@ -36,10 +38,10 @@ extension STTextViewController { // Filters - setUpOpenPairFilters(pairs: pairsToHandle, whitespaceProvider: whitespaceProvider) + setUpOpenPairFilters(pairs: BracketPairs.allValues, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, indentOption: indentOption) - setUpDeletePairFilters(pairs: pairsToHandle) + setUpDeletePairFilters(pairs: BracketPairs.allValues) setUpDeleteWhitespaceFilter(indentOption: indentOption) } diff --git a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift index b7a31329e..871d59cf6 100644 --- a/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift +++ b/Sources/CodeEditTextView/TreeSitter/TreeSitterClient+Edit.swift @@ -79,10 +79,10 @@ extension TreeSitterClient { /// - Returns: Any changed ranges. internal func changedByteRanges(_ lhs: Tree?, rhs: Tree?) -> [Range] { switch (lhs, rhs) { - case (let t1?, let t2?): - return t1.changedRanges(from: t2).map({ $0.bytes }) - case (nil, let t2?): - let range = t2.rootNode?.byteRange + case (let tree1?, let tree2?): + return tree1.changedRanges(from: tree2).map({ $0.bytes }) + case (nil, let tree2?): + let range = tree2.rootNode?.byteRange return range.flatMap({ [$0] }) ?? [] case (_, nil): diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index 1f9914238..161299466 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -215,5 +215,75 @@ final class STTextViewControllerTests: XCTestCase { controller.letterSpacing = 1.0 } + + func test_bracketHighlights() { + controller.viewDidLoad() + controller.bracketPairHighlight = nil + controller.textView.string = "{ Loren Ipsum {} }" + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") + controller.setCursorPosition((1, 3)) + + controller.bracketPairHighlight = .bordered(color: .black) + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for bordered. Expected 2, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + + controller.bracketPairHighlight = .underline(color: .black) + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for underline. Expected 2, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + + controller.bracketPairHighlight = .flash + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") + + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 1, "Controller created more than one layer for flash animation. Expected 1, found \(controller.highlightLayers.count)") + let exp = expectation(description: "Test after 0.8 seconds") + let result = XCTWaiter.wait(for: [exp], timeout: 0.8) + if result == XCTWaiter.Result.timedOut { + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove layer after flash animation. Expected 0, found \(controller.highlightLayers.count)") + } else { + XCTFail("Delay interrupted") + } + } + + func test_findClosingPair() { + controller.textView.string = "{ Loren Ipsum {} }" + var idx: Int? + + // Test walking forwards + idx = controller.findClosingPair("{", "}", from: 1, limit: 18, reverse: false) + XCTAssert(idx == 17, "Walking forwards failed. Expected `17`, found: `\(String(describing: idx))`") + + // Test walking backwards + idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) + XCTAssert(idx == 0, "Walking backwards failed. Expected `0`, found: `\(String(describing: idx))`") + + // Test extra pair + controller.textView.string = "{ Loren Ipsum {}} }" + idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) + XCTAssert(idx == 16, "Walking forwards with extra bracket pair failed. Expected `16`, found: `\(String(describing: idx))`") + + // Text extra pair backwards + controller.textView.string = "{ Loren Ipsum {{} }" + idx = controller.findClosingPair("}", "{", from: 18, limit: 0, reverse: true) + XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") + + // Test missing pair + controller.textView.string = "{ Loren Ipsum { }" + idx = controller.findClosingPair("{", "}", from: 1, limit: 17, reverse: false) + XCTAssert(idx == nil, "Walking forwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + + // Test missing pair backwards + controller.textView.string = " Loren Ipsum {} }" + idx = controller.findClosingPair("}", "{", from: 17, limit: 0, reverse: true) + XCTAssert(idx == nil, "Walking backwards with missing pair failed. Expected `nil`, found: `\(String(describing: idx))`") + } } // swiftlint:enable all