From 5a11adaa1eaa0a2c9c74c54435c1a59f3421dba1 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 14 Oct 2023 20:06:32 -0500 Subject: [PATCH] Fix Editing Bugs, Pass Tests --- .../TextLayoutManager+Edits.swift | 10 +- .../TextLayoutManager+Public.swift | 2 +- .../TextLineStorage/TextLineStorage.swift | 6 +- .../CodeEditInputView/TextView/TextView.swift | 14 +- .../TextView/TextViewDelegate.swift | 12 +- .../CodeEditTextView/CodeEditTextView.swift | 2 +- .../TextViewController+Cursor.swift | 7 +- .../TextViewController+LoadView.swift | 118 +++++++ .../TextViewController+TextViewDelegate.swift | 26 ++ .../Controller/TextViewController.swift | 149 ++------- .../CodeEditTextView/Enums/CaptureName.swift | 2 +- .../Extensions/TextView+/TextView+Menu.swift | 2 +- .../TextView+/TextView+TextFormation.swift | 2 +- .../Filters/DeleteWhitespaceFilter.swift | 9 +- .../Highlighting/HighlightProviding.swift | 1 - .../Highlighting/Highlighter.swift | 2 +- .../STTextViewControllerTests.swift | 289 ----------------- .../TextViewControllerTests.swift | 296 ++++++++++++++++++ 18 files changed, 493 insertions(+), 456 deletions(-) create mode 100644 Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift create mode 100644 Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift delete mode 100644 Tests/CodeEditTextViewTests/STTextViewControllerTests.swift create mode 100644 Tests/CodeEditTextViewTests/TextViewControllerTests.swift diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift index fe3c94de5..52218307f 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Edits.swift @@ -66,7 +66,15 @@ extension TextLayoutManager: NSTextStorageDelegate { /// - insertedString: The string being inserted. /// - location: The location the string is being inserted into. private func applyLineInsert(_ insertedString: NSString, at location: Int) { - if LineEnding(line: insertedString as String) != nil { + if lineStorage.count == 0 && lineStorage.length == 0 { + // The text was completely empty before, insert. + lineStorage.insert( + line: TextLine(), + atIndex: location, + length: insertedString.length, + height: estimateLineHeight() + ) + } else if LineEnding(line: insertedString as String) != nil { // Need to split the line inserting into and create a new line with the split section of the line guard let linePosition = lineStorage.getLine(atIndex: location) else { return } let splitLocation = location + insertedString.length diff --git a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift index 238f0e0b2..51dd075fb 100644 --- a/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditInputView/TextLayoutManager/TextLayoutManager+Public.swift @@ -29,7 +29,7 @@ extension TextLayoutManager { /// - Parameter index: The line to find. /// - Returns: The text line position if any, `nil` if the index is out of bounds. public func textLineForIndex(_ index: Int) -> TextLineStorage.TextLinePosition? { - guard index > 0 && index < lineStorage.count else { return nil } + guard index >= 0 && index < lineStorage.count else { return nil } return lineStorage.getLine(atIndex: index) } diff --git a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift index 991845fee..f9f8f61cd 100644 --- a/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift +++ b/Sources/CodeEditInputView/TextLineStorage/TextLineStorage.swift @@ -486,9 +486,11 @@ private extension TextLineStorage { deltaHeight: CGFloat, nodeAction: MetaFixupAction = .none ) { - guard node.parent != nil else { return } + guard node.parent != nil, root != nil else { return } + let rootRef = Unmanaged>.passUnretained(root!) var ref = Unmanaged>.passUnretained(node) - while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), ref.takeUnretainedValue() !== root { + while let node = ref._withUnsafeGuaranteedRef({ $0.parent }), + ref.takeUnretainedValue() !== rootRef.takeUnretainedValue() { if node.left === ref.takeUnretainedValue() { node.leftSubtreeOffset += delta node.leftSubtreeHeight += deltaHeight diff --git a/Sources/CodeEditInputView/TextView/TextView.swift b/Sources/CodeEditInputView/TextView/TextView.swift index 02451bce6..34ac1a0ff 100644 --- a/Sources/CodeEditInputView/TextView/TextView.swift +++ b/Sources/CodeEditInputView/TextView/TextView.swift @@ -44,6 +44,7 @@ public class TextView: NSView, NSTextContent { textStorage.string } set { + layoutManager.willReplaceCharactersInRange(range: documentRange, with: newValue) textStorage.setAttributedString(NSAttributedString(string: newValue, attributes: typingAttributes)) } } @@ -95,12 +96,6 @@ public class TextView: NSView, NSTextContent { layoutManager?.wrapLines = newValue } } - public var editorOverscroll: CGFloat { - didSet { - setNeedsDisplay() - updateFrameIfNeeded() - } - } /// A multiplier that determines the amount of space between characters. `1.0` indicates no space, /// `2.0` indicates one character of space between other characters. @@ -188,7 +183,6 @@ public class TextView: NSView, NSTextContent { textColor: NSColor, lineHeight: CGFloat, wrapLines: Bool, - editorOverscroll: CGFloat, isEditable: Bool, letterSpacing: Double, delegate: TextViewDelegate, @@ -197,8 +191,6 @@ public class TextView: NSView, NSTextContent { self.delegate = delegate self.textStorage = NSTextStorage(string: string) self.storageDelegate = storageDelegate - - self.editorOverscroll = editorOverscroll self.isEditable = isEditable self.letterSpacing = letterSpacing self.allowsUndo = true @@ -399,8 +391,8 @@ public class TextView: NSView, NSTextContent { var didUpdate = false - if newHeight + editorOverscroll >= availableSize.height && frame.size.height != newHeight + editorOverscroll { - frame.size.height = newHeight + editorOverscroll + if newHeight >= availableSize.height && frame.size.height != newHeight { + frame.size.height = newHeight // No need to update layout after height adjustment } diff --git a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift index 36c03eefe..b977408c4 100644 --- a/Sources/CodeEditInputView/TextView/TextViewDelegate.swift +++ b/Sources/CodeEditInputView/TextView/TextViewDelegate.swift @@ -8,13 +8,13 @@ import Foundation public protocol TextViewDelegate: AnyObject { - func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with: String) - func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) - func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with: String) -> Bool + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool } public extension TextViewDelegate { - func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with: String) { } - func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { } - func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with: String) -> Bool { true } + func textView(_ textView: TextView, willReplaceContentsIn range: NSRange, with string: String) { } + func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) { } + func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { true } } diff --git a/Sources/CodeEditTextView/CodeEditTextView.swift b/Sources/CodeEditTextView/CodeEditTextView.swift index 0ac71abd9..db9cf1b30 100644 --- a/Sources/CodeEditTextView/CodeEditTextView.swift +++ b/Sources/CodeEditTextView/CodeEditTextView.swift @@ -87,7 +87,7 @@ public struct CodeEditTextView: NSViewControllerRepresentable { private var letterSpacing: Double private var bracketPairHighlight: BracketPairHighlight? - public typealias NSViewControllerType = TextViewController // STTextViewController + public typealias NSViewControllerType = TextViewController public func makeNSViewController(context: Context) -> TextViewController { return TextViewController( diff --git a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift index 0d5a1371d..8f5247b47 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController+Cursor.swift @@ -13,14 +13,15 @@ extension TextViewController { /// - Parameter position: The position to set. Lines and columns are 1-indexed. func setCursorPosition(_ position: (Int, Int)) { let (line, column) = position - guard line >= 0 && column >= 0 else { return } + guard line > 0 && column > 0 else { return } + + _ = textView.becomeFirstResponder() if textView.textStorage.length == 0 { // If the file is blank, automatically place the cursor in the first index. let range = NSRange(location: 0, length: 0) - _ = self.textView.becomeFirstResponder() self.textView.selectionManager.setSelectedRange(range) - } else if line - 1 >= 0, let linePosition = textView.layoutManager.textLineForIndex(line - 1) { + } else if let linePosition = textView.layoutManager.textLineForIndex(line - 1) { // If this is a valid line, set the new position let index = max( linePosition.range.lowerBound, diff --git a/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift new file mode 100644 index 000000000..1503ce332 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+LoadView.swift @@ -0,0 +1,118 @@ +// +// TextViewController+LoadView.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import CodeEditInputView +import AppKit + +extension TextViewController { + // swiftlint:disable:next function_body_length + override public func loadView() { + scrollView = NSScrollView() + textView = TextView( + string: string.wrappedValue, + font: font, + textColor: theme.text, + lineHeight: lineHeightMultiple, + wrapLines: wrapLines, + isEditable: isEditable, + letterSpacing: letterSpacing, + delegate: self, + storageDelegate: storageDelegate + ) + textView.postsFrameChangedNotifications = true + textView.translatesAutoresizingMaskIntoConstraints = false + textView.selectionManager.insertionPointColor = theme.insertionPoint + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.postsFrameChangedNotifications = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.documentView = textView + scrollView.contentView.postsBoundsChangedNotifications = true + if let contentInsets { + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = contentInsets + } + + gutterView = GutterView( + font: font.rulerFont, + textColor: .secondaryLabelColor, + textView: textView, + delegate: self + ) + gutterView.frame.origin.y = -scrollView.contentInsets.top + gutterView.updateWidthIfNeeded() + scrollView.addFloatingSubview( + gutterView, + for: .horizontal + ) + + self.view = scrollView + setUpHighlighter() + + 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) + ]) + + // Layout on scroll change + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + } + + // Layout on frame change + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak self] _ in + self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) + self?.gutterView.needsDisplay = true + if self?.bracketPairHighlight == .flash { + self?.removeHighlightLayers() + } + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: textView, + queue: .main + ) { [weak self] _ in + self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 + self?.gutterView.needsDisplay = true + } + + NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: textView.selectionManager, + queue: .main + ) { [weak self] _ in + self?.updateCursorPosition() + self?.highlightSelectionPairs() + } + + textView.updateFrameIfNeeded() + + 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) + } +} diff --git a/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift new file mode 100644 index 000000000..667489ec4 --- /dev/null +++ b/Sources/CodeEditTextView/Controller/TextViewController+TextViewDelegate.swift @@ -0,0 +1,26 @@ +// +// TextViewController+TextViewDelegate.swift +// +// +// Created by Khan Winter on 10/14/23. +// + +import Foundation +import CodeEditInputView +import TextStory + +extension TextViewController: TextViewDelegate { + public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { + gutterView.needsDisplay = true + } + + public func textView(_ textView: TextView, shouldReplaceContentsIn range: NSRange, with string: String) -> Bool { + let mutation = TextMutation( + string: string, + range: range, + limit: textView.textStorage.length + ) + + return shouldApplyMutation(mutation, to: textView) + } +} diff --git a/Sources/CodeEditTextView/Controller/TextViewController.swift b/Sources/CodeEditTextView/Controller/TextViewController.swift index b589d4e67..ab9a898fa 100644 --- a/Sources/CodeEditTextView/Controller/TextViewController.swift +++ b/Sources/CodeEditTextView/Controller/TextViewController.swift @@ -19,7 +19,7 @@ public class TextViewController: NSViewController { var gutterView: GutterView! /// Internal reference to any injected layers in the text view. internal var highlightLayers: [CALayer] = [] - private var systemAppearance: NSAppearance.Name? + internal var systemAppearance: NSAppearance.Name? /// Binding for the `textView`s string public var string: Binding @@ -76,12 +76,11 @@ public class TextViewController: NSViewController { /// The current cursor position e.g. (1, 1) public var cursorPosition: Binding<(Int, Int)> - /// The height to overscroll the textview by. - public var editorOverscroll: CGFloat { - didSet { - textView.editorOverscroll = editorOverscroll - } - } + /// The editorOverscroll to use for the textView over scroll + /// + /// Measured in a percentage of the view's total height, meaning a `0.3` value will result in overscroll + /// of 1/3 of the view. + public var editorOverscroll: CGFloat /// Whether the code editor should use the theme background color or be transparent public var useThemeBackground: Bool @@ -122,12 +121,19 @@ public class TextViewController: NSViewController { /// Filters used when applying edits.. internal var textFilters: [TextFormation.Filter] = [] - /// The pixel value to overscroll the bottom of the editor. - /// Calculated as the line height \* ``TextViewController/editorOverscroll``. - /// Does not include ``TextViewController/contentInsets``. - private var bottomContentInset: CGFloat { (textView?.estimatedLineHeight() ?? 0) * CGFloat(editorOverscroll) } + internal var cancellables = Set() + + /// ScrollView's bottom inset using as editor overscroll + private var bottomContentInsets: CGFloat { + let height = view.frame.height + var inset = editorOverscroll * height - private var cancellables = Set() + if height - inset < font.lineHeight * lineHeightMultiple { + inset = height - font.lineHeight * lineHeightMultiple + } + + return max(inset, .zero) + } // MARK: Init @@ -188,121 +194,10 @@ public class TextViewController: NSViewController { return paragraph } - // MARK: Load View - - // swiftlint:disable:next function_body_length - override public func loadView() { - scrollView = NSScrollView() - textView = TextView( - string: string.wrappedValue, - font: font, - textColor: theme.text, - lineHeight: lineHeightMultiple, - wrapLines: wrapLines, - editorOverscroll: bottomContentInset, - isEditable: isEditable, - letterSpacing: letterSpacing, - delegate: self, - storageDelegate: storageDelegate - ) - textView.postsFrameChangedNotifications = true - textView.translatesAutoresizingMaskIntoConstraints = false - textView.selectionManager.insertionPointColor = theme.insertionPoint - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.contentView.postsFrameChangedNotifications = true - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true - scrollView.documentView = textView - scrollView.contentView.postsBoundsChangedNotifications = true - if let contentInsets { - scrollView.automaticallyAdjustsContentInsets = false - scrollView.contentInsets = contentInsets - } - - gutterView = GutterView( - font: font.rulerFont, - textColor: .secondaryLabelColor, - textView: textView, - delegate: self - ) - gutterView.frame.origin.y = -scrollView.contentInsets.top - gutterView.updateWidthIfNeeded() - scrollView.addFloatingSubview( - gutterView, - for: .horizontal - ) - - self.view = scrollView - setUpHighlighter() - - 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) - ]) - - // Layout on scroll change - NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) - self?.gutterView.needsDisplay = true - } - - // Layout on frame change - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: scrollView.contentView, - queue: .main - ) { [weak self] _ in - self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) - self?.gutterView.needsDisplay = true - if self?.bracketPairHighlight == .flash { - self?.removeHighlightLayers() - } - } - - NotificationCenter.default.addObserver( - forName: NSView.frameDidChangeNotification, - object: textView, - queue: .main - ) { [weak self] _ in - self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10 - self?.gutterView.needsDisplay = true - } - - NotificationCenter.default.addObserver( - forName: TextSelectionManager.selectionChangedNotification, - object: textView.selectionManager, - queue: .main - ) { [weak self] _ in - self?.updateCursorPosition() - self?.highlightSelectionPairs() - } - - textView.updateFrameIfNeeded() - - 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) - } - // MARK: - Reload UI func reloadUI() { textView.isEditable = isEditable - textView.editorOverscroll = bottomContentInset textView.selectionManager.selectionBackgroundColor = theme.selection textView.selectionManager.selectedLineBackgroundColor = useThemeBackground @@ -331,7 +226,7 @@ public class TextViewController: NSViewController { if let contentInsets = contentInsets { scrollView.contentInsets = contentInsets } - scrollView.contentInsets.bottom = bottomContentInset + (contentInsets?.bottom ?? 0) + scrollView.contentInsets.bottom = (contentInsets?.bottom ?? 0) + bottomContentInsets } highlighter?.invalidate() @@ -346,12 +241,6 @@ public class TextViewController: NSViewController { } } -extension TextViewController: TextViewDelegate { - public func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with: String) { - gutterView.needsDisplay = true - } -} - extension TextViewController: GutterViewDelegate { public func gutterViewWidthDidUpdate(newWidth: CGFloat) { gutterView?.frame.size.width = newWidth diff --git a/Sources/CodeEditTextView/Enums/CaptureName.swift b/Sources/CodeEditTextView/Enums/CaptureName.swift index 9c8599cad..0112782a8 100644 --- a/Sources/CodeEditTextView/Enums/CaptureName.swift +++ b/Sources/CodeEditTextView/Enums/CaptureName.swift @@ -1,5 +1,5 @@ // -// STTextViewController+CaptureNames.swift +// CaptureNames.swift // CodeEditTextView // // Created by Lukas Pistrol on 16.08.22. diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift index 576380c51..2baa4b200 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+Menu.swift @@ -1,5 +1,5 @@ // -// STTextView+Menu.swift +// TextView+Menu.swift // CodeEditTextView // // Created by Lukas Pistrol on 25.05.22. diff --git a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift index 612248472..3844ab86d 100644 --- a/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditTextView/Extensions/TextView+/TextView+TextFormation.swift @@ -35,6 +35,6 @@ extension TextView: TextInterface { /// Applies the mutation to the text view. /// - Parameter mutation: The mutation to apply. public func applyMutation(_ mutation: TextMutation) { - replaceCharacters(in: mutation.range, with: mutation.string) + textStorage.replaceCharacters(in: mutation.range, with: mutation.string) } } diff --git a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift index a5f92579b..bfac27860 100644 --- a/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift +++ b/Sources/CodeEditTextView/Filters/DeleteWhitespaceFilter.swift @@ -33,7 +33,8 @@ struct DeleteWhitespaceFilter: Filter { in interface: TextInterface, with providers: WhitespaceProviders ) -> FilterAction { - guard mutation.string == "" + guard mutation.delta < 0 + && mutation.string == "" && mutation.range.length == 1 && indentOption != .tab else { return .none @@ -59,12 +60,6 @@ struct DeleteWhitespaceFilter: Filter { ) ) - if let textView = interface as? TextView, textView.selectionManager.textSelections.count == 1 { - textView.selectionManager.setSelectedRange( - NSRange(location: leadingWhitespace.max - numberOfExtraSpaces, length: 0) - ) - } - return .discard } } diff --git a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift index 88ed36ea8..9ac761987 100644 --- a/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift +++ b/Sources/CodeEditTextView/Highlighting/HighlightProviding.swift @@ -7,7 +7,6 @@ import Foundation import CodeEditLanguages -import STTextView import AppKit /// The protocol a class must conform to to be used for highlighting. diff --git a/Sources/CodeEditTextView/Highlighting/Highlighter.swift b/Sources/CodeEditTextView/Highlighting/Highlighter.swift index c7ee5ed2f..a2485d7e3 100644 --- a/Sources/CodeEditTextView/Highlighting/Highlighter.swift +++ b/Sources/CodeEditTextView/Highlighting/Highlighter.swift @@ -11,7 +11,7 @@ import CodeEditInputView import SwiftTreeSitter import CodeEditLanguages -/// The `Highlighter` class handles efficiently highlighting the `STTextView` it's provided with. +/// The `Highlighter` class handles efficiently highlighting the `TextView` it's provided with. /// It will listen for text and visibility changes, and highlight syntax as needed. /// /// One should rarely have to direcly modify or call methods on this class. Just keep it alive in diff --git a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift b/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift deleted file mode 100644 index 5f2f28b78..000000000 --- a/Tests/CodeEditTextViewTests/STTextViewControllerTests.swift +++ /dev/null @@ -1,289 +0,0 @@ -import XCTest -@testable import CodeEditTextView -import SwiftTreeSitter -import AppKit -import TextStory - -// swiftlint:disable all -final class STTextViewControllerTests: XCTestCase { - -// var controller: STTextViewController! -// var theme: EditorTheme! -// -// override func setUpWithError() throws { -// theme = EditorTheme( -// text: .textColor, -// insertionPoint: .textColor, -// invisibles: .gray, -// background: .textBackgroundColor, -// lineHighlight: .highlightColor, -// selection: .selectedTextColor, -// keywords: .systemPink, -// commands: .systemBlue, -// types: .systemMint, -// attributes: .systemTeal, -// variables: .systemCyan, -// values: .systemOrange, -// numbers: .systemYellow, -// strings: .systemRed, -// characters: .systemRed, -// comments: .systemGreen -// ) -// controller = STTextViewController( -// text: .constant(""), -// language: .default, -// font: .monospacedSystemFont(ofSize: 11, weight: .medium), -// theme: theme, -// tabWidth: 4, -// indentOption: .spaces(count: 4), -// lineHeight: 1.0, -// wrapLines: true, -// cursorPosition: .constant((1, 1)), -// editorOverscroll: 0.5, -// useThemeBackground: true, -// isEditable: true, -// letterSpacing: 1.0 -// ) -// -// controller.loadView() -// } -// -// func test_captureNames() throws { -// // test for "keyword" -// let captureName1 = "keyword" -// let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor -// XCTAssertEqual(color1, NSColor.systemPink) -// -// // test for "comment" -// let captureName2 = "comment" -// let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor -// XCTAssertEqual(color2, NSColor.systemGreen) -// -// /* ... additional tests here ... */ -// -// // test for empty case -// let captureName3 = "" -// let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor -// XCTAssertEqual(color3, NSColor.textColor) -// -// // test for random case -// let captureName4 = "abc123" -// let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor -// XCTAssertEqual(color4, NSColor.textColor) -// } -// -// func test_editorOverScroll() throws { -// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) -// scrollView.frame = .init(x: .zero, -// y: .zero, -// width: 100, -// height: 100) -// -// controller.editorOverscroll = 0 -// controller.contentInsets = nil -// controller.reloadUI() -// -// // editorOverscroll: 0 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) -// -// controller.editorOverscroll = 0.5 -// controller.reloadUI() -// -// // editorOverscroll: 0.5 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 50.0) -// -// controller.editorOverscroll = 1.0 -// controller.reloadUI() -// -// // editorOverscroll: 1.0 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) -// } -// -// func test_editorInsets() throws { -// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) -// scrollView.frame = .init(x: .zero, -// y: .zero, -// width: 100, -// height: 100) -// -// func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { -// XCTAssertEqual(lhs.top, rhs.top) -// XCTAssertEqual(lhs.right, rhs.right) -// XCTAssertEqual(lhs.bottom, rhs.bottom) -// XCTAssertEqual(lhs.left, rhs.left) -// } -// -// controller.editorOverscroll = 0 -// controller.contentInsets = nil -// controller.reloadUI() -// -// // contentInsets: 0 -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) -// -// // contentInsets: 16 -// controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) -// controller.reloadUI() -// -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) -// -// // contentInsets: different -// controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) -// controller.reloadUI() -// -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) -// -// // contentInsets: 16 -// // editorOverscroll: 0.5 -// controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) -// controller.editorOverscroll = 0.5 -// controller.reloadUI() -// -// try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16 + 50, right: 16)) -// } -// -// func test_editorOverScroll_ZeroCondition() throws { -// let scrollView = try XCTUnwrap(controller.view as? NSScrollView) -// scrollView.frame = .zero -// -// // editorOverscroll: 0 -// XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) -// } -// -// func test_indentOptionString() { -// XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) -// XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) -// -// XCTAssertEqual("\t", IndentOption.tab.stringValue) -// } -// -// func test_indentBehavior() { -// // Insert 1 space -// controller.indentOption = .spaces(count: 1) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.insertTab(nil) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert 2 spaces -// controller.indentOption = .spaces(count: 2) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert 3 spaces -// controller.indentOption = .spaces(count: 3) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert 4 spaces -// controller.indentOption = .spaces(count: 4) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, " ") -// -// // Insert tab -// controller.indentOption = .tab -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, "\t") -// -// // Insert lots of spaces -// controller.indentOption = .spaces(count: 1000) -// controller.textView.textContentStorage?.textStorage?.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textContentStorage?.textStorage?.length ?? 0), with: "") -// controller.textView.insertText("\t", replacementRange: .zero) -// XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) -// } -// -// func test_letterSpacing() { -// let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) -// -// controller.letterSpacing = 1.0 -// -// XCTAssertEqual( -// controller.attributesFor(nil)[.kern]! as! CGFloat, -// (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 -// ) -// -// controller.letterSpacing = 2.0 -// XCTAssertEqual( -// controller.attributesFor(nil)[.kern]! as! CGFloat, -// (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 -// ) -// -// 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 diff --git a/Tests/CodeEditTextViewTests/TextViewControllerTests.swift b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift new file mode 100644 index 000000000..bbbde3267 --- /dev/null +++ b/Tests/CodeEditTextViewTests/TextViewControllerTests.swift @@ -0,0 +1,296 @@ +import XCTest +@testable import CodeEditTextView +import SwiftTreeSitter +import AppKit +import SwiftUI +import TextStory + +// swiftlint:disable all +final class TextViewControllerTests: XCTestCase { + + var controller: TextViewController! + var theme: EditorTheme! + + override func setUpWithError() throws { + theme = EditorTheme( + text: .textColor, + insertionPoint: .textColor, + invisibles: .gray, + background: .textBackgroundColor, + lineHighlight: .highlightColor, + selection: .selectedTextColor, + keywords: .systemPink, + commands: .systemBlue, + types: .systemMint, + attributes: .systemTeal, + variables: .systemCyan, + values: .systemOrange, + numbers: .systemYellow, + strings: .systemRed, + characters: .systemRed, + comments: .systemGreen + ) + controller = TextViewController( + string: .constant(""), + language: .default, + font: .monospacedSystemFont(ofSize: 11, weight: .medium), + theme: theme, + tabWidth: 4, + indentOption: .spaces(count: 4), + lineHeight: 1.0, + wrapLines: true, + cursorPosition: .constant((1, 1)), + editorOverscroll: 0.5, + useThemeBackground: true, + highlightProvider: nil, + contentInsets: NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + isEditable: true, + letterSpacing: 1.0, + bracketPairHighlight: .flash + ) + + controller.loadView() + } + + func test_captureNames() throws { + // test for "keyword" + let captureName1 = "keyword" + let color1 = controller.attributesFor(CaptureName(rawValue: captureName1))[.foregroundColor] as? NSColor + XCTAssertEqual(color1, NSColor.systemPink) + + // test for "comment" + let captureName2 = "comment" + let color2 = controller.attributesFor(CaptureName(rawValue: captureName2))[.foregroundColor] as? NSColor + XCTAssertEqual(color2, NSColor.systemGreen) + + /* ... additional tests here ... */ + + // test for empty case + let captureName3 = "" + let color3 = controller.attributesFor(CaptureName(rawValue: captureName3))[.foregroundColor] as? NSColor + XCTAssertEqual(color3, NSColor.textColor) + + // test for random case + let captureName4 = "abc123" + let color4 = controller.attributesFor(CaptureName(rawValue: captureName4))[.foregroundColor] as? NSColor + XCTAssertEqual(color4, NSColor.textColor) + } + + func test_editorOverScroll() throws { + let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + scrollView.frame = .init(x: .zero, + y: .zero, + width: 100, + height: 100) + + controller.editorOverscroll = 0 + controller.contentInsets = nil + controller.reloadUI() + + // editorOverscroll: 0 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) + + controller.editorOverscroll = 0.5 + controller.reloadUI() + + // editorOverscroll: 0.5 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 50.0) + + controller.editorOverscroll = 1.0 + controller.reloadUI() + + // editorOverscroll: 1.0 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 87.0) + } + + func test_editorInsets() throws { + let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + scrollView.frame = .init(x: .zero, + y: .zero, + width: 100, + height: 100) + + func assertInsetsEqual(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) throws { + XCTAssertEqual(lhs.top, rhs.top) + XCTAssertEqual(lhs.right, rhs.right) + XCTAssertEqual(lhs.bottom, rhs.bottom) + XCTAssertEqual(lhs.left, rhs.left) + } + + controller.editorOverscroll = 0 + controller.contentInsets = nil + controller.reloadUI() + + // contentInsets: 0 + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)) + + // contentInsets: 16 + controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.reloadUI() + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)) + + // contentInsets: different + controller.contentInsets = NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1) + controller.reloadUI() + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 32.5, left: 12.3, bottom: 20, right: 1)) + + // contentInsets: 16 + // editorOverscroll: 0.5 + controller.contentInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + controller.editorOverscroll = 0.5 + controller.reloadUI() + + try assertInsetsEqual(scrollView.contentInsets, NSEdgeInsets(top: 16, left: 16, bottom: 16 + 50, right: 16)) + } + + func test_editorOverScroll_ZeroCondition() throws { + let scrollView = try XCTUnwrap(controller.view as? NSScrollView) + scrollView.frame = .zero + + // editorOverscroll: 0 + XCTAssertEqual(scrollView.contentView.contentInsets.bottom, 0) + } + + func test_indentOptionString() { + XCTAssertEqual(" ", IndentOption.spaces(count: 1).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 2).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 3).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 4).stringValue) + XCTAssertEqual(" ", IndentOption.spaces(count: 5).stringValue) + + XCTAssertEqual("\t", IndentOption.tab.stringValue) + } + + func test_indentBehavior() { + // Insert 1 space + controller.indentOption = .spaces(count: 1) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 2 spaces + controller.indentOption = .spaces(count: 2) + controller.textView.textStorage.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 3 spaces + controller.indentOption = .spaces(count: 3) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert 4 spaces + controller.indentOption = .spaces(count: 4) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, " ") + + // Insert tab + controller.indentOption = .tab + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, "\t") + + // Insert lots of spaces + controller.indentOption = .spaces(count: 1000) + print(controller.textView.textStorage.length) + controller.textView.replaceCharacters(in: NSRange(location: 0, length: controller.textView.textStorage.length), with: "") + controller.textView.insertText("\t", replacementRange: .zero) + XCTAssertEqual(controller.textView.string, String(repeating: " ", count: 1000)) + } + + func test_letterSpacing() { + let font: NSFont = .monospacedSystemFont(ofSize: 11, weight: .medium) + + controller.letterSpacing = 1.0 + + XCTAssertEqual( + controller.attributesFor(nil)[.kern]! as! CGFloat, + (" " as NSString).size(withAttributes: [.font: font]).width * 0.0 + ) + + controller.letterSpacing = 2.0 + XCTAssertEqual( + controller.attributesFor(nil)[.kern]! as! CGFloat, + (" " as NSString).size(withAttributes: [.font: font]).width * 1.0 + ) + + 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.textView.setNeedsDisplay() + 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