Skip to content

Commit

Permalink
Fix Marked Text Input (#40)
Browse files Browse the repository at this point in the history
### Description

Fixes marked text input for sequences longer than one marked character. Also ensures marked text works consistently with multiple cursors and adds testing for the marked text functionality.

Also:
- Fixes a few lint markers in test files that have caused issues for others.
- Adds a public `TextView.markedTextAttributes` property for modifying the marked text attributes if desired.

### Related Issues

* closes #37 
* closes #36 
* closes #26 
* closes CodeEditApp/CodeEditSourceEditor#188

### Screenshots

https://github.com/CodeEditApp/CodeEditTextView/assets/35942988/9f6eb84b-c668-45a4-9d30-75cbd5d4fccd
  • Loading branch information
thecoolwinter authored Jun 22, 2024
1 parent 40458fe commit eb1d382
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 59 deletions.
5 changes: 4 additions & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
excluded:
- .build

disabled_rules:
- todo
- trailing_comma
Expand All @@ -13,4 +16,4 @@ identifier_name:
excluded:
- c
- id
- vc
- vc
46 changes: 29 additions & 17 deletions Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 {
Expand All @@ -31,32 +35,40 @@ 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.
/// - Parameter lineRange: The range of the line.
/// - 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 {
Expand All @@ -65,7 +77,7 @@ class MarkedTextManager {
if ranges.isEmpty {
return nil
} else {
return MarkedRanges(ranges: ranges, attributes: attributes)
return MarkedRanges(ranges: ranges, attributes: markedTextAttributes)
}
}

Expand Down
30 changes: 20 additions & 10 deletions Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
}

Expand Down
12 changes: 12 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 18 additions & 27 deletions Tests/CodeEditTextViewTests/LineEndingTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import XCTest
@testable import CodeEditTextView

// swiftlint:disable all

class LineEndingTests: XCTestCase {
func test_lineEndingCreateUnix() {
// The \n character
Expand All @@ -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<TextLine>()
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..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String(
(0..<Int.random(in: 1..<20)).map { _ in corpus.randomElement()! }
) + goalLineEnding.rawValue
}
}

func test_detectLineEndingUnix() {
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
let goalLineEnding = LineEnding.lineFeed

let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
}

let storage = NSTextStorage(string: text)
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
let lineStorage = TextLineStorage<TextLine>()
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..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
}

let storage = NSTextStorage(string: text)
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
let lineStorage = TextLineStorage<TextLine>()
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..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
}

let storage = NSTextStorage(string: text)
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
let lineStorage = TextLineStorage<TextLine>()
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
127 changes: 127 additions & 0 deletions Tests/CodeEditTextViewTests/MarkedTextTests.swift
Original file line number Diff line number Diff line change
@@ -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)]
)
}
}
Loading

0 comments on commit eb1d382

Please sign in to comment.