diff --git a/.swiftlint.yml b/.swiftlint.yml index 12e68359..ea0c8485 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,3 +1,6 @@ +excluded: + - .build + disabled_rules: - todo - trailing_comma @@ -13,4 +16,4 @@ identifier_name: excluded: - c - id - - vc \ No newline at end of file + - vc diff --git a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift index ea788e1d..5206389e 100644 --- a/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift +++ b/Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift @@ -7,8 +7,10 @@ import AppKit -/// Manages marked ranges +/// Manages marked ranges. Not a public API. class MarkedTextManager { + /// Struct for passing attribute and range information easily down into line fragments, typesetters w/o + /// requiring a reference to the marked text manager. struct MarkedRanges { let ranges: [NSRange] let attributes: [NSAttributedString.Key: Any] @@ -18,7 +20,9 @@ class MarkedTextManager { private(set) var markedRanges: [NSRange] = [] /// The attributes to use for marked text. Defaults to a single underline when `nil` - var markedTextAttributes: [NSAttributedString.Key: Any]? + var markedTextAttributes: [NSAttributedString.Key: Any] = [ + .underlineStyle: NSUnderlineStyle.single.rawValue + ] /// True if there is marked text being tracked. var hasMarkedText: Bool { @@ -31,24 +35,33 @@ class MarkedTextManager { } /// Updates the stored marked ranges. + /// + /// Two cases here: + /// - No marked ranges yet: + /// - Create new marked ranges from the text selection, with the length of the text being inserted + /// - Marked ranges exist: + /// - Update the existing marked ranges, using the original ranges as a reference. The marked ranges don't + /// change position, so we update each one with the new length and then move it to reflect each cursor's + /// added text. + /// /// - Parameters: /// - insertLength: The length of the string being inserted. - /// - replacementRange: The range to replace with marked text. - /// - selectedRange: The selected range from `NSTextInput`. /// - textSelections: The current text selections. - func updateMarkedRanges( - insertLength: Int, - replacementRange: NSRange, - selectedRange: NSRange, - textSelections: [TextSelectionManager.TextSelection] - ) { - if replacementRange.location == NSNotFound { - markedRanges = textSelections.map { - NSRange(location: $0.range.location, length: insertLength) - } + func updateMarkedRanges(insertLength: Int, textSelections: [NSRange]) { + var cumulativeExistingDiff = 0 + let lengthDiff = insertLength + var newRanges = [NSRange]() + let ranges: [NSRange] = if markedRanges.isEmpty { + textSelections.sorted(by: { $0.location < $1.location }) } else { - markedRanges = [selectedRange] + markedRanges.sorted(by: { $0.location < $1.location }) + } + + for (idx, range) in ranges.enumerated() { + newRanges.append(NSRange(location: range.location + cumulativeExistingDiff, length: insertLength)) + cumulativeExistingDiff += insertLength - range.length } + markedRanges = newRanges } /// Finds any marked ranges for a line and returns them. @@ -56,7 +69,6 @@ class MarkedTextManager { /// - Returns: A `MarkedRange` struct with information about attributes and ranges. `nil` if there is no marked /// text for this line. func markedRanges(in lineRange: NSRange) -> MarkedRanges? { - let attributes = markedTextAttributes ?? [.underlineStyle: NSUnderlineStyle.single.rawValue] let ranges = markedRanges.compactMap { $0.intersection(lineRange) }.map { @@ -65,7 +77,7 @@ class MarkedTextManager { if ranges.isEmpty { return nil } else { - return MarkedRanges(ranges: ranges, attributes: attributes) + return MarkedRanges(ranges: ranges, attributes: markedTextAttributes) } } diff --git a/Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift b/Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift index 4ddff7c0..d64a5a0b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift +++ b/Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift @@ -80,13 +80,10 @@ extension TextView: NSTextInputClient { // MARK: - Marked Text - /// Replaces a specified range in the receiver’s text storage with the given string and sets the selection. + /// Sets up marked text for a marking session. See ``MarkedTextManager`` for more details. /// - /// If there is no marked text, the current selection is replaced. If there is no selection, the string is - /// inserted at the insertion point. - /// - /// When `string` is an `NSString` object, the receiver is expected to render the marked text with - /// distinguishing appearance (for example, `NSTextView` renders with `markedTextAttributes`). + /// Decides whether or not to insert/replace text. Then updates the current marked ranges and updates cursor + /// positions. /// /// - Parameters: /// - string: The string to insert. Can be either an NSString or NSAttributedString instance. @@ -96,13 +93,26 @@ extension TextView: NSTextInputClient { guard isEditable, let insertString = anyToString(string) else { return } // Needs to insert text, but not notify the undo manager. _undoManager?.disable() + let shouldInsert = layoutManager.markedTextManager.markedRanges.isEmpty + + // Copy the text selections *before* we modify them. + let selectionCopies = selectionManager.textSelections.map(\.range) + + if shouldInsert { + _insertText(insertString: insertString, replacementRange: replacementRange) + } else { + replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: insertString) + } layoutManager.markedTextManager.updateMarkedRanges( insertLength: (insertString as NSString).length, - replacementRange: replacementRange, - selectedRange: selectedRange, - textSelections: selectionManager.textSelections + textSelections: selectionCopies ) - _insertText(insertString: insertString, replacementRange: replacementRange) + + // Reset the selected ranges to reflect the replaced text. + selectionManager.setSelectedRanges(layoutManager.markedTextManager.markedRanges.map({ + NSRange(location: $0.max, length: 0) + })) + _undoManager?.enable() } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 41235559..4c625612 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -198,6 +198,18 @@ public class TextView: NSView, NSTextContent { } } + /// The attributes used to render marked text. + /// Defaults to a single underline. + public var markedTextAttributes: [NSAttributedString.Key: Any] { + get { + layoutManager.markedTextManager.markedTextAttributes + } + set { + layoutManager.markedTextManager.markedTextAttributes = newValue + layoutManager.layoutLines() // Layout lines to refresh attributes. This should be rare. + } + } + open var contentType: NSTextContentType? /// The text view's delegate. diff --git a/Tests/CodeEditTextViewTests/LineEndingTests.swift b/Tests/CodeEditTextViewTests/LineEndingTests.swift index 3162906a..c46f50f6 100644 --- a/Tests/CodeEditTextViewTests/LineEndingTests.swift +++ b/Tests/CodeEditTextViewTests/LineEndingTests.swift @@ -1,8 +1,6 @@ import XCTest @testable import CodeEditTextView -// swiftlint:disable all - class LineEndingTests: XCTestCase { func test_lineEndingCreateUnix() { // The \n character @@ -29,64 +27,57 @@ class LineEndingTests: XCTestCase { } func test_detectLineEndingDefault() { - // There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not flaky. + // There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not + // flaky. // The odds of it being bad with the earlier bug after running 20 times is incredibly small for _ in 0..<20 { let storage = NSTextStorage(string: "hello world") // No line ending let lineStorage = TextLineStorage() lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == .lineFeed, "Default detected line ending incorrect, expected: \n, got: \(detected.rawValue.debugDescription)") + XCTAssertEqual(detected, .lineFeed) + } + } + + let corpus = "abcdefghijklmnopqrstuvwxyz123456789" + func makeRandomText(_ goalLineEnding: LineEnding) -> String { + (10..() lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") + XCTAssertEqual(detected, goalLineEnding) } func test_detectLineEndingCLRF() { - let corpus = "abcdefghijklmnopqrstuvwxyz123456789" let goalLineEnding = LineEnding.carriageReturnLineFeed - let text = (10..() lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") + XCTAssertEqual(detected, goalLineEnding) } func test_detectLineEndingMacOS() { - let corpus = "abcdefghijklmnopqrstuvwxyz123456789" let goalLineEnding = LineEnding.carriageReturn - let text = (10..() lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10) let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage) - XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)") + XCTAssertEqual(detected, goalLineEnding) } } - -// swiftlint:enable all diff --git a/Tests/CodeEditTextViewTests/MarkedTextTests.swift b/Tests/CodeEditTextViewTests/MarkedTextTests.swift new file mode 100644 index 00000000..41233716 --- /dev/null +++ b/Tests/CodeEditTextViewTests/MarkedTextTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import CodeEditTextView + +class MarkedTextTests: XCTestCase { + func test_markedTextSingleChar() { + let textView = TextView(string: "") + textView.selectionManager.setSelectedRange(.zero) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "´") + + textView.insertText("é", replacementRange: .notFound) + XCTAssertEqual(textView.string, "é") + XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 1, length: 0)]) + } + + func test_markedTextSingleCharInStrings() { + let textView = TextView(string: "Lorem Ipsum") + textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "Lorem´ Ipsum") + + textView.insertText("é", replacementRange: .notFound) + XCTAssertEqual(textView.string, "Loremé Ipsum") + XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 6, length: 0)]) + } + + func test_markedTextReplaceSelection() { + let textView = TextView(string: "ABCDE") + textView.selectionManager.setSelectedRange(NSRange(location: 4, length: 1)) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "ABCD´") + + textView.insertText("é", replacementRange: .notFound) + XCTAssertEqual(textView.string, "ABCDé") + XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 5, length: 0)]) + } + + func test_markedTextMultipleSelection() { + let textView = TextView(string: "ABC") + textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)]) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "A´B´C") + + textView.insertText("é", replacementRange: .notFound) + XCTAssertEqual(textView.string, "AéBéC") + XCTAssertEqual( + textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), + [NSRange(location: 2, length: 0), NSRange(location: 4, length: 0)] + ) + } + + func test_markedTextMultipleSelectionReplaceSelection() { + let textView = TextView(string: "ABCDE") + textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)]) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "´BCD´") + + textView.insertText("é", replacementRange: .notFound) + XCTAssertEqual(textView.string, "éBCDé") + XCTAssertEqual( + textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), + [NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)] + ) + } + + func test_markedTextMultipleSelectionMultipleChar() { + let textView = TextView(string: "ABCDE") + textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)]) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "´BCD´") + + textView.setMarkedText("´´´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "´´´BCD´´´") + XCTAssertEqual( + textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), + [NSRange(location: 3, length: 0), NSRange(location: 9, length: 0)] + ) + + textView.insertText("é", replacementRange: .notFound) + XCTAssertEqual(textView.string, "éBCDé") + XCTAssertEqual( + textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), + [NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)] + ) + } + + func test_cancelMarkedText() { + let textView = TextView(string: "") + textView.selectionManager.setSelectedRange(.zero) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "´") + + // The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the + // marked text: + textView.insertText("´", replacementRange: .notFound) + textView.insertText("4", replacementRange: .notFound) + + XCTAssertEqual(textView.string, "´4") + XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 2, length: 0)]) + } + + func test_cancelMarkedTextMultipleCursor() { + let textView = TextView(string: "ABC") + textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)]) + + textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound) + XCTAssertEqual(textView.string, "A´B´C") + + // The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the + // marked text: + textView.insertText("´", replacementRange: .notFound) + textView.insertText("4", replacementRange: .notFound) + + XCTAssertEqual(textView.string, "A´4B´4C") + XCTAssertEqual( + textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }), + [NSRange(location: 3, length: 0), NSRange(location: 6, length: 0)] + ) + } +} diff --git a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift index d6ab376f..1b613199 100644 --- a/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift +++ b/Tests/CodeEditTextViewTests/TextLayoutLineStorageTests.swift @@ -1,8 +1,6 @@ import XCTest @testable import CodeEditTextView -// swiftlint:disable function_body_length - fileprivate extension CGFloat { func approxEqual(_ value: CGFloat) -> Bool { return abs(self - value) < 0.05 @@ -141,6 +139,7 @@ final class TextLayoutLineStorageTests: XCTestCase { } } + // swiftlint:disable:next function_body_length func test_delete() throws { var tree = TextLineStorage() @@ -274,5 +273,3 @@ final class TextLayoutLineStorageTests: XCTestCase { } } } - -// swiftlint:enable function_body_length