Skip to content

Commit eb1d382

Browse files
Fix Marked Text Input (#40)
### 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
1 parent 40458fe commit eb1d382

File tree

7 files changed

+211
-59
lines changed

7 files changed

+211
-59
lines changed

.swiftlint.yml

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
excluded:
2+
- .build
3+
14
disabled_rules:
25
- todo
36
- trailing_comma
@@ -13,4 +16,4 @@ identifier_name:
1316
excluded:
1417
- c
1518
- id
16-
- vc
19+
- vc

Sources/CodeEditTextView/MarkedTextManager/MarkedTextManager.swift

+29-17
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
import AppKit
99

10-
/// Manages marked ranges
10+
/// Manages marked ranges. Not a public API.
1111
class MarkedTextManager {
12+
/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o
13+
/// requiring a reference to the marked text manager.
1214
struct MarkedRanges {
1315
let ranges: [NSRange]
1416
let attributes: [NSAttributedString.Key: Any]
@@ -18,7 +20,9 @@ class MarkedTextManager {
1820
private(set) var markedRanges: [NSRange] = []
1921

2022
/// The attributes to use for marked text. Defaults to a single underline when `nil`
21-
var markedTextAttributes: [NSAttributedString.Key: Any]?
23+
var markedTextAttributes: [NSAttributedString.Key: Any] = [
24+
.underlineStyle: NSUnderlineStyle.single.rawValue
25+
]
2226

2327
/// True if there is marked text being tracked.
2428
var hasMarkedText: Bool {
@@ -31,32 +35,40 @@ class MarkedTextManager {
3135
}
3236

3337
/// Updates the stored marked ranges.
38+
///
39+
/// Two cases here:
40+
/// - No marked ranges yet:
41+
/// - Create new marked ranges from the text selection, with the length of the text being inserted
42+
/// - Marked ranges exist:
43+
/// - Update the existing marked ranges, using the original ranges as a reference. The marked ranges don't
44+
/// change position, so we update each one with the new length and then move it to reflect each cursor's
45+
/// added text.
46+
///
3447
/// - Parameters:
3548
/// - insertLength: The length of the string being inserted.
36-
/// - replacementRange: The range to replace with marked text.
37-
/// - selectedRange: The selected range from `NSTextInput`.
3849
/// - textSelections: The current text selections.
39-
func updateMarkedRanges(
40-
insertLength: Int,
41-
replacementRange: NSRange,
42-
selectedRange: NSRange,
43-
textSelections: [TextSelectionManager.TextSelection]
44-
) {
45-
if replacementRange.location == NSNotFound {
46-
markedRanges = textSelections.map {
47-
NSRange(location: $0.range.location, length: insertLength)
48-
}
50+
func updateMarkedRanges(insertLength: Int, textSelections: [NSRange]) {
51+
var cumulativeExistingDiff = 0
52+
let lengthDiff = insertLength
53+
var newRanges = [NSRange]()
54+
let ranges: [NSRange] = if markedRanges.isEmpty {
55+
textSelections.sorted(by: { $0.location < $1.location })
4956
} else {
50-
markedRanges = [selectedRange]
57+
markedRanges.sorted(by: { $0.location < $1.location })
58+
}
59+
60+
for (idx, range) in ranges.enumerated() {
61+
newRanges.append(NSRange(location: range.location + cumulativeExistingDiff, length: insertLength))
62+
cumulativeExistingDiff += insertLength - range.length
5163
}
64+
markedRanges = newRanges
5265
}
5366

5467
/// Finds any marked ranges for a line and returns them.
5568
/// - Parameter lineRange: The range of the line.
5669
/// - Returns: A `MarkedRange` struct with information about attributes and ranges. `nil` if there is no marked
5770
/// text for this line.
5871
func markedRanges(in lineRange: NSRange) -> MarkedRanges? {
59-
let attributes = markedTextAttributes ?? [.underlineStyle: NSUnderlineStyle.single.rawValue]
6072
let ranges = markedRanges.compactMap {
6173
$0.intersection(lineRange)
6274
}.map {
@@ -65,7 +77,7 @@ class MarkedTextManager {
6577
if ranges.isEmpty {
6678
return nil
6779
} else {
68-
return MarkedRanges(ranges: ranges, attributes: attributes)
80+
return MarkedRanges(ranges: ranges, attributes: markedTextAttributes)
6981
}
7082
}
7183

Sources/CodeEditTextView/TextView/TextView+NSTextInput.swift

+20-10
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,10 @@ extension TextView: NSTextInputClient {
8080

8181
// MARK: - Marked Text
8282

83-
/// Replaces a specified range in the receiver’s text storage with the given string and sets the selection.
83+
/// Sets up marked text for a marking session. See ``MarkedTextManager`` for more details.
8484
///
85-
/// If there is no marked text, the current selection is replaced. If there is no selection, the string is
86-
/// inserted at the insertion point.
87-
///
88-
/// When `string` is an `NSString` object, the receiver is expected to render the marked text with
89-
/// distinguishing appearance (for example, `NSTextView` renders with `markedTextAttributes`).
85+
/// Decides whether or not to insert/replace text. Then updates the current marked ranges and updates cursor
86+
/// positions.
9087
///
9188
/// - Parameters:
9289
/// - string: The string to insert. Can be either an NSString or NSAttributedString instance.
@@ -96,13 +93,26 @@ extension TextView: NSTextInputClient {
9693
guard isEditable, let insertString = anyToString(string) else { return }
9794
// Needs to insert text, but not notify the undo manager.
9895
_undoManager?.disable()
96+
let shouldInsert = layoutManager.markedTextManager.markedRanges.isEmpty
97+
98+
// Copy the text selections *before* we modify them.
99+
let selectionCopies = selectionManager.textSelections.map(\.range)
100+
101+
if shouldInsert {
102+
_insertText(insertString: insertString, replacementRange: replacementRange)
103+
} else {
104+
replaceCharacters(in: layoutManager.markedTextManager.markedRanges, with: insertString)
105+
}
99106
layoutManager.markedTextManager.updateMarkedRanges(
100107
insertLength: (insertString as NSString).length,
101-
replacementRange: replacementRange,
102-
selectedRange: selectedRange,
103-
textSelections: selectionManager.textSelections
108+
textSelections: selectionCopies
104109
)
105-
_insertText(insertString: insertString, replacementRange: replacementRange)
110+
111+
// Reset the selected ranges to reflect the replaced text.
112+
selectionManager.setSelectedRanges(layoutManager.markedTextManager.markedRanges.map({
113+
NSRange(location: $0.max, length: 0)
114+
}))
115+
106116
_undoManager?.enable()
107117
}
108118

Sources/CodeEditTextView/TextView/TextView.swift

+12
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,18 @@ public class TextView: NSView, NSTextContent {
198198
}
199199
}
200200

201+
/// The attributes used to render marked text.
202+
/// Defaults to a single underline.
203+
public var markedTextAttributes: [NSAttributedString.Key: Any] {
204+
get {
205+
layoutManager.markedTextManager.markedTextAttributes
206+
}
207+
set {
208+
layoutManager.markedTextManager.markedTextAttributes = newValue
209+
layoutManager.layoutLines() // Layout lines to refresh attributes. This should be rare.
210+
}
211+
}
212+
201213
open var contentType: NSTextContentType?
202214

203215
/// The text view's delegate.
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import XCTest
22
@testable import CodeEditTextView
33

4-
// swiftlint:disable all
5-
64
class LineEndingTests: XCTestCase {
75
func test_lineEndingCreateUnix() {
86
// The \n character
@@ -29,64 +27,57 @@ class LineEndingTests: XCTestCase {
2927
}
3028

3129
func test_detectLineEndingDefault() {
32-
// 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.
30+
// There was a bug in this that caused it to flake sometimes, so we run this a couple times to ensure it's not
31+
// flaky.
3332
// The odds of it being bad with the earlier bug after running 20 times is incredibly small
3433
for _ in 0..<20 {
3534
let storage = NSTextStorage(string: "hello world") // No line ending
3635
let lineStorage = TextLineStorage<TextLine>()
3736
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)
3837
let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
39-
XCTAssertTrue(detected == .lineFeed, "Default detected line ending incorrect, expected: \n, got: \(detected.rawValue.debugDescription)")
38+
XCTAssertEqual(detected, .lineFeed)
39+
}
40+
}
41+
42+
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
43+
func makeRandomText(_ goalLineEnding: LineEnding) -> String {
44+
(10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
45+
return partialResult + String(
46+
(0..<Int.random(in: 1..<20)).map { _ in corpus.randomElement()! }
47+
) + goalLineEnding.rawValue
4048
}
4149
}
4250

4351
func test_detectLineEndingUnix() {
44-
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
4552
let goalLineEnding = LineEnding.lineFeed
4653

47-
let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
48-
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
49-
}
50-
51-
let storage = NSTextStorage(string: text)
54+
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
5255
let lineStorage = TextLineStorage<TextLine>()
5356
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)
5457

5558
let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
56-
XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)")
59+
XCTAssertEqual(detected, goalLineEnding)
5760
}
5861

5962
func test_detectLineEndingCLRF() {
60-
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
6163
let goalLineEnding = LineEnding.carriageReturnLineFeed
6264

63-
let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
64-
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
65-
}
66-
67-
let storage = NSTextStorage(string: text)
65+
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
6866
let lineStorage = TextLineStorage<TextLine>()
6967
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)
7068

7169
let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
72-
XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)")
70+
XCTAssertEqual(detected, goalLineEnding)
7371
}
7472

7573
func test_detectLineEndingMacOS() {
76-
let corpus = "abcdefghijklmnopqrstuvwxyz123456789"
7774
let goalLineEnding = LineEnding.carriageReturn
7875

79-
let text = (10..<Int.random(in: 20..<100)).reduce("") { partialResult, _ in
80-
return partialResult + String((0..<Int.random(in: 1..<20)).map{ _ in corpus.randomElement()! }) + goalLineEnding.rawValue
81-
}
82-
83-
let storage = NSTextStorage(string: text)
76+
let storage = NSTextStorage(string: makeRandomText(goalLineEnding))
8477
let lineStorage = TextLineStorage<TextLine>()
8578
lineStorage.buildFromTextStorage(storage, estimatedLineHeight: 10)
8679

8780
let detected = LineEnding.detectLineEnding(lineStorage: lineStorage, textStorage: storage)
88-
XCTAssertTrue(detected == goalLineEnding, "Incorrect detected line ending, expected: \(goalLineEnding.rawValue.debugDescription), got \(detected.rawValue.debugDescription)")
81+
XCTAssertEqual(detected, goalLineEnding)
8982
}
9083
}
91-
92-
// swiftlint:enable all
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import XCTest
2+
@testable import CodeEditTextView
3+
4+
class MarkedTextTests: XCTestCase {
5+
func test_markedTextSingleChar() {
6+
let textView = TextView(string: "")
7+
textView.selectionManager.setSelectedRange(.zero)
8+
9+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
10+
XCTAssertEqual(textView.string, "´")
11+
12+
textView.insertText("é", replacementRange: .notFound)
13+
XCTAssertEqual(textView.string, "é")
14+
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 1, length: 0)])
15+
}
16+
17+
func test_markedTextSingleCharInStrings() {
18+
let textView = TextView(string: "Lorem Ipsum")
19+
textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0))
20+
21+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
22+
XCTAssertEqual(textView.string, "Lorem´ Ipsum")
23+
24+
textView.insertText("é", replacementRange: .notFound)
25+
XCTAssertEqual(textView.string, "Loremé Ipsum")
26+
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 6, length: 0)])
27+
}
28+
29+
func test_markedTextReplaceSelection() {
30+
let textView = TextView(string: "ABCDE")
31+
textView.selectionManager.setSelectedRange(NSRange(location: 4, length: 1))
32+
33+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
34+
XCTAssertEqual(textView.string, "ABCD´")
35+
36+
textView.insertText("é", replacementRange: .notFound)
37+
XCTAssertEqual(textView.string, "ABCDé")
38+
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 5, length: 0)])
39+
}
40+
41+
func test_markedTextMultipleSelection() {
42+
let textView = TextView(string: "ABC")
43+
textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)])
44+
45+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
46+
XCTAssertEqual(textView.string, "A´B´C")
47+
48+
textView.insertText("é", replacementRange: .notFound)
49+
XCTAssertEqual(textView.string, "AéBéC")
50+
XCTAssertEqual(
51+
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
52+
[NSRange(location: 2, length: 0), NSRange(location: 4, length: 0)]
53+
)
54+
}
55+
56+
func test_markedTextMultipleSelectionReplaceSelection() {
57+
let textView = TextView(string: "ABCDE")
58+
textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)])
59+
60+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
61+
XCTAssertEqual(textView.string, "´BCD´")
62+
63+
textView.insertText("é", replacementRange: .notFound)
64+
XCTAssertEqual(textView.string, "éBCDé")
65+
XCTAssertEqual(
66+
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
67+
[NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)]
68+
)
69+
}
70+
71+
func test_markedTextMultipleSelectionMultipleChar() {
72+
let textView = TextView(string: "ABCDE")
73+
textView.selectionManager.setSelectedRanges([NSRange(location: 0, length: 1), NSRange(location: 4, length: 1)])
74+
75+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
76+
XCTAssertEqual(textView.string, "´BCD´")
77+
78+
textView.setMarkedText("´´´", selectedRange: .notFound, replacementRange: .notFound)
79+
XCTAssertEqual(textView.string, "´´´BCD´´´")
80+
XCTAssertEqual(
81+
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
82+
[NSRange(location: 3, length: 0), NSRange(location: 9, length: 0)]
83+
)
84+
85+
textView.insertText("é", replacementRange: .notFound)
86+
XCTAssertEqual(textView.string, "éBCDé")
87+
XCTAssertEqual(
88+
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
89+
[NSRange(location: 1, length: 0), NSRange(location: 5, length: 0)]
90+
)
91+
}
92+
93+
func test_cancelMarkedText() {
94+
let textView = TextView(string: "")
95+
textView.selectionManager.setSelectedRange(.zero)
96+
97+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
98+
XCTAssertEqual(textView.string, "´")
99+
100+
// The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the
101+
// marked text:
102+
textView.insertText("´", replacementRange: .notFound)
103+
textView.insertText("4", replacementRange: .notFound)
104+
105+
XCTAssertEqual(textView.string, "´4")
106+
XCTAssertEqual(textView.selectionManager.textSelections.map(\.range), [NSRange(location: 2, length: 0)])
107+
}
108+
109+
func test_cancelMarkedTextMultipleCursor() {
110+
let textView = TextView(string: "ABC")
111+
textView.selectionManager.setSelectedRanges([NSRange(location: 1, length: 0), NSRange(location: 2, length: 0)])
112+
113+
textView.setMarkedText("´", selectedRange: .notFound, replacementRange: .notFound)
114+
XCTAssertEqual(textView.string, "A´B´C")
115+
116+
// The NSTextInputContext performs the following actions when a marked text segment is ended w/o replacing the
117+
// marked text:
118+
textView.insertText("´", replacementRange: .notFound)
119+
textView.insertText("4", replacementRange: .notFound)
120+
121+
XCTAssertEqual(textView.string, "A´4B´4C")
122+
XCTAssertEqual(
123+
textView.selectionManager.textSelections.map(\.range).sorted(by: { $0.location < $1.location }),
124+
[NSRange(location: 3, length: 0), NSRange(location: 6, length: 0)]
125+
)
126+
}
127+
}

0 commit comments

Comments
 (0)