From dc80f9b94763851dbd24f91a28a5f6962004a139 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 3 May 2023 16:13:36 -0500 Subject: [PATCH 1/7] Add brace pair highlighting --- .../CodeEditTextView/CodeEditTextView.swift | 14 +- .../STTextViewController+HighlightRange.swift | 215 ++++++++++++++++++ .../STTextViewController+Lifecycle.swift | 144 ++++++++++++ .../Controller/STTextViewController.swift | 153 ++----------- .../Enums/BracePairHighlight.swift | 18 ++ .../STTextViewController+TextFormation.swift | 20 +- .../TreeSitter/TreeSitterClient+Edit.swift | 8 +- .../STTextViewControllerTests.swift | 63 +++++ 8 files changed, 490 insertions(+), 145 deletions(-) create mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift create mode 100644 Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift create mode 100644 Sources/CodeEditTextView/Enums/BracePairHighlight.swift diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 4dc39dd74..c340f42ce 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` + /// - bracePairHighlight: The type of highlight to use to highlight brace pairs. + /// See `BracePairHighlight` for more information. 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, + bracePairHighlight: BracePairHighlight? = 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.bracePairHighlight = bracePairHighlight } @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 bracePairHighlight: BracePairHighlight? public typealias NSViewControllerType = STTextViewController @@ -101,7 +106,8 @@ public struct CodeEditTextView: NSViewControllerRepresentable { highlightProvider: highlightProvider, contentInsets: contentInsets, isEditable: isEditable, - letterSpacing: letterSpacing + letterSpacing: letterSpacing, + bracePairHighlight: bracePairHighlight ) return controller } @@ -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 { @@ -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 } } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift new file mode 100644 index 000000000..7ac49de1c --- /dev/null +++ b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift @@ -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`. + public 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() + } +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift new file mode 100644 index 000000000..4df7397d9 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift @@ -0,0 +1,144 @@ +// +// 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?.removeHighlightLayers() + self?.updateCursorPosition() + self?.highlightSelectionPairs() + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: (self.view as? NSScrollView)?.verticalRulerView, + queue: .main + ) { [weak self] _ in + self?.removeHighlightLayers() + 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() + } +} diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index f5543a82d..4570b327b 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,15 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } } + /// The type of highlight to use when highlighting brace pairs. Leave as `nil` to disable highlighting. + public var bracePairHighlight: BracePairHighlight? { + didSet { + removeHighlightLayers() + // Update selection highlights if needed. + highlightSelectionPairs() + } + } + /// 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 +135,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt highlightProvider: HighlightProviding? = nil, contentInsets: NSEdgeInsets? = nil, isEditable: Bool, - letterSpacing: Double + letterSpacing: Double, + bracePairHighlight: BracePairHighlight? = nil ) { self.text = text self.language = language @@ -135,6 +152,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable + self.bracePairHighlight = bracePairHighlight super.init(nibName: nil, bundle: nil) } @@ -142,132 +160,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 +180,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 @@ -350,7 +242,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 !highlightLayers.isEmpty { + removeHighlightLayers() + } } public override func insertTab(_ sender: Any?) { @@ -358,6 +252,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } deinit { + removeHighlightLayers() textView = nil highlighter = nil cancellables.forEach { $0.cancel() } diff --git a/Sources/CodeEditTextView/Enums/BracePairHighlight.swift b/Sources/CodeEditTextView/Enums/BracePairHighlight.swift new file mode 100644 index 000000000..9574d4e4b --- /dev/null +++ b/Sources/CodeEditTextView/Enums/BracePairHighlight.swift @@ -0,0 +1,18 @@ +// +// BracePairHighlight.swift +// CodeEditTextView +// +// Created by Khan Winter on 5/3/23. +// + +/// An enum representing the type of highlight to use for brace pairs. +public enum BracePairHighlight { + /// Highlight both the opening and closing character in a pair with a bounding box. + /// The box will use the theme's text color with some opacity. The boxes will stay on screen until the cursor moves + /// away from the brace pair. + case box + /// Flash a yellow highlight box on only the opposite character in the pair. + /// This is closely matched to Xcode's flash highlight for brace pairs, and animates in and out over the course + /// of `0.75` seconds. + case flash +} diff --git a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift index 202e00181..c62fb79c5 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 BracePairs { + 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: BracePairs.allValues, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, indentOption: indentOption) - setUpDeletePairFilters(pairs: pairsToHandle) + setUpDeletePairFilters(pairs: BracePairs.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..cb5a6528b 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -215,5 +215,68 @@ final class STTextViewControllerTests: XCTestCase { controller.letterSpacing = 1.0 } + + func test_braceHighlights() { + controller.viewDidLoad() + controller.bracePairHighlight = 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.bracePairHighlight = .box + controller.setCursorPosition((1, 2)) // After first opening { + XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for the box. Expected 2, found \(controller.highlightLayers.count)") + controller.setCursorPosition((1, 3)) + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove brace pair layers.") + + controller.bracePairHighlight = .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 brace 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 brace 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 brace 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 From f675fbc817335bcd1f2fa69b8d427ee544a0f3e7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 3 May 2023 16:38:02 -0500 Subject: [PATCH 2/7] Make `highlightRange(...)` private --- .../Controller/STTextViewController+HighlightRange.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift index 7ac49de1c..db4485251 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift @@ -106,7 +106,7 @@ extension STTextViewController { /// - 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: NSTextRange, scrollToRange: Bool = false) { + private func highlightRange(_ range: NSTextRange, scrollToRange: Bool = false) { guard let bracePairHighlight = bracePairHighlight, var rectToHighlight = textView.textLayoutManager.textSelectionSegmentFrame( in: range, type: .highlight From 8a27dd8a51581cafd6476e572ed77c0e3d0f623a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 3 May 2023 16:50:45 -0500 Subject: [PATCH 3/7] Pin `STTextView` --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From f8b4b268ee36d2b08ec39176cb9008cadb2a0516 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 7 May 2023 20:13:21 -0500 Subject: [PATCH 4/7] Rename to `bracket`, add `.underline(color:)` --- .../CodeEditTextView/CodeEditTextView.swift | 16 +++---- .../STTextViewController+HighlightRange.swift | 38 +++++++++++----- .../Controller/STTextViewController.swift | 8 ++-- .../Enums/BracePairHighlight.swift | 18 -------- .../Enums/BracketPairHighlight.swift | 45 +++++++++++++++++++ .../STTextViewController+TextFormation.swift | 6 +-- .../STTextViewControllerTests.swift | 16 +++---- 7 files changed, 94 insertions(+), 53 deletions(-) delete mode 100644 Sources/CodeEditTextView/Enums/BracePairHighlight.swift create mode 100644 Sources/CodeEditTextView/Enums/BracketPairHighlight.swift diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index c340f42ce..a0ac4390e 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -33,8 +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. + /// - 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, @@ -51,7 +51,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { contentInsets: NSEdgeInsets? = nil, isEditable: Bool = true, letterSpacing: Double = 1.0, - bracePairHighlight: BracePairHighlight? = nil + bracketPairHighlight: BracketPairHighlight? = nil ) { self._text = text self.language = language @@ -68,7 +68,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { self.contentInsets = contentInsets self.isEditable = isEditable self.letterSpacing = letterSpacing - self.bracePairHighlight = bracePairHighlight + self.bracketPairHighlight = bracketPairHighlight } @Binding private var text: String @@ -86,7 +86,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var contentInsets: NSEdgeInsets? private var isEditable: Bool private var letterSpacing: Double - private var bracePairHighlight: BracePairHighlight? + private var bracketPairHighlight: BracketPairHighlight? public typealias NSViewControllerType = STTextViewController @@ -107,7 +107,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { contentInsets: contentInsets, isEditable: isEditable, letterSpacing: letterSpacing, - bracePairHighlight: bracePairHighlight + bracketPairHighlight: bracketPairHighlight ) return controller } @@ -125,7 +125,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.lineHeightMultiple = lineHeight controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets - controller.bracePairHighlight = bracePairHighlight + 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 { @@ -160,6 +160,6 @@ public struct CodeEditTextView: NSViewControllerRepresentable { controller.indentOption == indentOption && controller.tabWidth == tabWidth && controller.letterSpacing == letterSpacing && - controller.bracePairHighlight == bracePairHighlight + controller.bracketPairHighlight == bracketPairHighlight } } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift index db4485251..f71e05ed3 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift @@ -9,9 +9,9 @@ import AppKit import STTextView extension STTextViewController { - /// Highlights brace pairs using the current selection. + /// Highlights bracket pairs using the current selection. internal func highlightSelectionPairs() { - guard bracePairHighlight != nil else { return } + guard bracketPairHighlight != nil else { return } for selection in textView.textLayoutManager.textSelections.flatMap(\.textRanges) { if selection.isEmpty, let range = selection.nsRange(using: textView.textContentManager), @@ -19,7 +19,7 @@ extension STTextViewController { let preceedingCharacter = textView.textContentStorage?.textStorage?.substring( from: NSRange(location: range.location - 1, length: 1) // The preceeding character exists ) { - for pair in BracePairs.allValues { + for pair in BracketPairs.allValues { if preceedingCharacter == pair.0 { // Walk forwards if let characterIndex = findClosingPair( @@ -31,7 +31,7 @@ extension STTextViewController { reverse: false ) { highlightRange(NSRange(location: characterIndex, length: 1)) - if bracePairHighlight == .box { + if bracketPairHighlight?.highlightsSourceBracket ?? false { highlightRange(NSRange(location: range.location - 1, length: 1)) } } @@ -46,7 +46,7 @@ extension STTextViewController { reverse: true ) { highlightRange(NSRange(location: characterIndex, length: 1)) - if bracePairHighlight == .box { + if bracketPairHighlight?.highlightsSourceBracket ?? false { highlightRange(NSRange(location: range.location - 1, length: 1)) } } @@ -107,7 +107,7 @@ extension STTextViewController { /// - 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, + guard let bracketPairHighlight = bracketPairHighlight, var rectToHighlight = textView.textLayoutManager.textSelectionSegmentFrame( in: range, type: .highlight ) else { @@ -115,7 +115,7 @@ extension STTextViewController { } let layer = CAShapeLayer() - switch bracePairHighlight { + switch bracketPairHighlight { case .flash: rectToHighlight.size.width += 4 rectToHighlight.origin.x -= 2 @@ -127,18 +127,32 @@ extension STTextViewController { layer.shadowOffset = CGSize(width: 0, height: 1) layer.shadowRadius = 3.0 layer.opacity = 0.0 - case .box: - layer.borderColor = theme.text.cgColor + case .bordered(let borderColor): + layer.borderColor = borderColor.cgColor + layer.cornerRadius = 2.5 layer.borderWidth = 0.5 - layer.opacity = 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 } - layer.frame = rectToHighlight + switch bracketPairHighlight { + case .flash, .bordered: + layer.frame = rectToHighlight + case .underline: + let path = CGMutablePath() + path.move(to: CGPoint(x: rectToHighlight.minX, y: rectToHighlight.maxY)) + path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: rectToHighlight.maxY)) + layer.path = path + } // Insert above selection but below text textView.layer?.insertSublayer(layer, at: 1) - if bracePairHighlight == .flash { + if bracketPairHighlight == .flash { addFlashAnimation(to: layer, rectToHighlight: rectToHighlight) } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 4570b327b..4785c6267 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -95,8 +95,8 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt } } - /// The type of highlight to use when highlighting brace pairs. Leave as `nil` to disable highlighting. - public var bracePairHighlight: BracePairHighlight? { + /// The type of highlight to use when highlighting bracket pairs. Leave as `nil` to disable highlighting. + public var bracketPairHighlight: BracketPairHighlight? { didSet { removeHighlightLayers() // Update selection highlights if needed. @@ -136,7 +136,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt contentInsets: NSEdgeInsets? = nil, isEditable: Bool, letterSpacing: Double, - bracePairHighlight: BracePairHighlight? = nil + bracketPairHighlight: BracketPairHighlight? = nil ) { self.text = text self.language = language @@ -152,7 +152,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt self.highlightProvider = highlightProvider self.contentInsets = contentInsets self.isEditable = isEditable - self.bracePairHighlight = bracePairHighlight + self.bracketPairHighlight = bracketPairHighlight super.init(nibName: nil, bundle: nil) } diff --git a/Sources/CodeEditTextView/Enums/BracePairHighlight.swift b/Sources/CodeEditTextView/Enums/BracePairHighlight.swift deleted file mode 100644 index 9574d4e4b..000000000 --- a/Sources/CodeEditTextView/Enums/BracePairHighlight.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// BracePairHighlight.swift -// CodeEditTextView -// -// Created by Khan Winter on 5/3/23. -// - -/// An enum representing the type of highlight to use for brace pairs. -public enum BracePairHighlight { - /// Highlight both the opening and closing character in a pair with a bounding box. - /// The box will use the theme's text color with some opacity. The boxes will stay on screen until the cursor moves - /// away from the brace pair. - case box - /// Flash a yellow highlight box on only the opposite character in the pair. - /// This is closely matched to Xcode's flash highlight for brace pairs, and animates in and out over the course - /// of `0.75` seconds. - case flash -} 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 c62fb79c5..5e24773ed 100644 --- a/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift +++ b/Sources/CodeEditTextView/Filters/STTextViewController+TextFormation.swift @@ -12,7 +12,7 @@ import TextStory extension STTextViewController { - internal enum BracePairs { + internal enum BracketPairs { static let allValues: [(String, String)] = [ ("{", "}"), ("[", "]"), @@ -38,10 +38,10 @@ extension STTextViewController { // Filters - setUpOpenPairFilters(pairs: BracePairs.allValues, whitespaceProvider: whitespaceProvider) + setUpOpenPairFilters(pairs: BracketPairs.allValues, whitespaceProvider: whitespaceProvider) setUpNewlineTabFilters(whitespaceProvider: whitespaceProvider, indentOption: indentOption) - setUpDeletePairFilters(pairs: BracePairs.allValues) + setUpDeletePairFilters(pairs: BracketPairs.allValues) setUpDeleteWhitespaceFilter(indentOption: indentOption) } diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index cb5a6528b..c3de0ba8e 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -216,24 +216,24 @@ final class STTextViewControllerTests: XCTestCase { controller.letterSpacing = 1.0 } - func test_braceHighlights() { + func test_bracketHighlights() { controller.viewDidLoad() - controller.bracePairHighlight = nil + 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.bracePairHighlight = .box + controller.bracketPairHighlight = .bordered controller.setCursorPosition((1, 2)) // After first opening { XCTAssert(controller.highlightLayers.count == 2, "Controller created an incorrect number of layers for the box. Expected 2, found \(controller.highlightLayers.count)") controller.setCursorPosition((1, 3)) - XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove brace pair layers.") + XCTAssert(controller.highlightLayers.isEmpty, "Controller failed to remove bracket pair layers.") - controller.bracePairHighlight = .flash + 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 brace pair layers.") + 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)") @@ -261,12 +261,12 @@ final class STTextViewControllerTests: XCTestCase { // Test extra pair controller.textView.string = "{ Loren Ipsum {}} }" idx = controller.findClosingPair("{", "}", from: 1, limit: 19, reverse: false) - XCTAssert(idx == 16, "Walking forwards with extra brace pair failed. Expected `16`, found: `\(String(describing: idx))`") + 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 brace pair failed. Expected `14`, found: `\(String(describing: idx))`") + XCTAssert(idx == 14, "Walking backwards with extra bracket pair failed. Expected `14`, found: `\(String(describing: idx))`") // Test missing pair controller.textView.string = "{ Loren Ipsum { }" From 79098f03eb6d474338fda3050ca8753d1934be20 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 7 May 2023 20:17:03 -0500 Subject: [PATCH 5/7] Add `underline(color:)` test --- .../STTextViewControllerTests.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index c3de0ba8e..9fd465b27 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -223,9 +223,15 @@ final class STTextViewControllerTests: XCTestCase { controller.setCursorPosition((1, 2)) // After first opening { XCTAssert(controller.highlightLayers.isEmpty, "Controller added highlight layer when setting is set to `nil`") - controller.bracketPairHighlight = .bordered + 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 the box. Expected 2, found \(controller.highlightLayers.count)") + 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.") From 3ebaf9e27a6cb7b5b1365482dbf9dab8db3d0653 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 8 May 2023 12:33:03 -0500 Subject: [PATCH 6/7] Adjust underline Y value --- .../Controller/STTextViewController+HighlightRange.swift | 5 +++-- .../Controller/STTextViewController+Lifecycle.swift | 3 +++ .../CodeEditTextView/Controller/STTextViewController.swift | 7 ++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift index f71e05ed3..39dab99e5 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift @@ -144,8 +144,9 @@ extension STTextViewController { layer.frame = rectToHighlight case .underline: let path = CGMutablePath() - path.move(to: CGPoint(x: rectToHighlight.minX, y: rectToHighlight.maxY)) - path.addLine(to: CGPoint(x: rectToHighlight.maxX, y: rectToHighlight.maxY)) + 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 } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift index 4df7397d9..a1dfef35d 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift @@ -121,6 +121,9 @@ extension STTextViewController { ) { [weak self] _ in self?.removeHighlightLayers() self?.updateTextContainerWidthIfNeeded() + if self?.bracketPairHighlight != .flash { + self?.highlightSelectionPairs() + } } systemAppearance = NSApp.effectiveAppearance.name diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index 4785c6267..c77f63907 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -99,8 +99,6 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt public var bracketPairHighlight: BracketPairHighlight? { didSet { removeHighlightLayers() - // Update selection highlights if needed. - highlightSelectionPairs() } } @@ -227,6 +225,9 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt highlighter?.invalidate() updateTextContainerWidthIfNeeded() + if bracketPairHighlight == .flash { + highlightSelectionPairs() + } } /// Calculated line height depending on ``STTextViewController/lineHeightMultiple`` @@ -242,7 +243,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt // MARK: Selectors override public func keyDown(with event: NSEvent) { - if !highlightLayers.isEmpty { + if bracketPairHighlight == .flash && !highlightLayers.isEmpty { removeHighlightLayers() } } From 59fe2b97ddeef757ecaf05cc5063961a52c71b4b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 8 May 2023 14:14:03 -0500 Subject: [PATCH 7/7] Fix flaky test, `removeHighlightLayers` --- ...t => STTextViewController+HighlightBracket.swift} | 1 + .../Controller/STTextViewController+Lifecycle.swift | 6 ++---- .../Controller/STTextViewController.swift | 12 +++--------- .../STTextViewControllerTests.swift | 1 + 4 files changed, 7 insertions(+), 13 deletions(-) rename Sources/CodeEditTextView/Controller/{STTextViewController+HighlightRange.swift => STTextViewController+HighlightBracket.swift} (99%) diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift similarity index 99% rename from Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift rename to Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift index 39dab99e5..848b6f15d 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+HighlightRange.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+HighlightBracket.swift @@ -12,6 +12,7 @@ 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), diff --git a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift index a1dfef35d..8cb55c110 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController+Lifecycle.swift @@ -109,7 +109,6 @@ extension STTextViewController { } self?.lastTextSelections = textSelections ?? [] - self?.removeHighlightLayers() self?.updateCursorPosition() self?.highlightSelectionPairs() } @@ -119,10 +118,9 @@ extension STTextViewController { object: (self.view as? NSScrollView)?.verticalRulerView, queue: .main ) { [weak self] _ in - self?.removeHighlightLayers() self?.updateTextContainerWidthIfNeeded() - if self?.bracketPairHighlight != .flash { - self?.highlightSelectionPairs() + if self?.bracketPairHighlight == .flash { + self?.removeHighlightLayers() } } diff --git a/Sources/CodeEditTextView/Controller/STTextViewController.swift b/Sources/CodeEditTextView/Controller/STTextViewController.swift index c77f63907..3d7915cda 100644 --- a/Sources/CodeEditTextView/Controller/STTextViewController.swift +++ b/Sources/CodeEditTextView/Controller/STTextViewController.swift @@ -96,11 +96,7 @@ 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? { - didSet { - removeHighlightLayers() - } - } + 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 @@ -225,9 +221,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt highlighter?.invalidate() updateTextContainerWidthIfNeeded() - if bracketPairHighlight == .flash { - highlightSelectionPairs() - } + highlightSelectionPairs() } /// Calculated line height depending on ``STTextViewController/lineHeightMultiple`` @@ -243,7 +237,7 @@ public class STTextViewController: NSViewController, STTextViewDelegate, ThemeAt // MARK: Selectors override public func keyDown(with event: NSEvent) { - if bracketPairHighlight == .flash && !highlightLayers.isEmpty { + if bracketPairHighlight == .flash { removeHighlightLayers() } } diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift index 9fd465b27..161299466 100644 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift @@ -222,6 +222,7 @@ final class STTextViewControllerTests: XCTestCase { 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 {