forked from CodeEditApp/CodeEditTextView
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTextLayoutManagerTests.swift
More file actions
253 lines (215 loc) · 9.79 KB
/
TextLayoutManagerTests.swift
File metadata and controls
253 lines (215 loc) · 9.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
import Testing
import AppKit
@testable import CodeEditTextView
extension TextLineStorage {
/// Validate that the internal tree is intact and correct.
///
/// Ensures that:
/// - All lines can be queried by their index starting from `0`.
/// - All lines can be found by iterating `y` positions.
func validateInternalState() {
func validateLines(_ lines: [TextLineStorage<Data>.TextLinePosition]) {
var _lastLine: TextLineStorage<Data>.TextLinePosition?
for line in lines {
guard let lastLine = _lastLine else {
#expect(line.index == 0)
_lastLine = line
return
}
#expect(line.index == lastLine.index + 1)
#expect(line.yPos >= lastLine.yPos + lastLine.height)
#expect(line.range.location == lastLine.range.max + 1)
_lastLine = line
}
}
let linesUsingIndex = (0..<count).compactMap({ getLine(atIndex: $0) })
validateLines(linesUsingIndex)
let linesUsingYValue = Array(linesStartingAt(0, until: height))
validateLines(linesUsingYValue)
}
}
@Suite
@MainActor
struct TextLayoutManagerTests {
let textView: TextView
let textStorage: NSTextStorage
let layoutManager: TextLayoutManager
init() throws {
textView = TextView(string: "A\nB\nC\nD")
textView.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000)
textView.updateFrameIfNeeded()
textStorage = textView.textStorage
layoutManager = try #require(textView.layoutManager)
}
@Test(
arguments: [
("\nE", NSRange(location: 6, length: 0), 5),
("0\n", NSRange(location: 0, length: 0), 5), // at beginning
("A\nBC\nD", NSRange(location: 3, length: 0), 6), // in middle
("A\r\nB\nC\rD", NSRange(location: 0, length: 0), 7) // Insert mixed line breaks
]
)
func insertText(_ testItem: (String, NSRange, Int)) throws { // swiftlint:disable:this large_tuple
let (insertText, insertRange, lineCount) = testItem
textStorage.replaceCharacters(in: insertRange, with: insertText)
#expect(layoutManager.lineCount == lineCount)
#expect(layoutManager.lineStorage.length == textStorage.length)
layoutManager.lineStorage.validateInternalState()
}
@Test(
arguments: [
(NSRange(location: 5, length: 2), 3), // At end
(NSRange(location: 0, length: 2), 3), // At beginning
(NSRange(location: 2, length: 3), 3) // In middle
]
)
func deleteText(_ testItem: (NSRange, Int)) throws {
let (deleteRange, lineCount) = testItem
textStorage.deleteCharacters(in: deleteRange)
#expect(layoutManager.lineCount == lineCount)
#expect(layoutManager.lineStorage.length == textStorage.length)
layoutManager.lineStorage.validateInternalState()
}
@Test(
arguments: [
("\nD\nE\nF", NSRange(location: 5, length: 2), 6), // At end
("A\nY\nZ", NSRange(location: 0, length: 1), 6), // At beginning
("1\n2\n", NSRange(location: 2, length: 4), 4), // In middle
("A\nB\nC\nD\nE\nF\nG", NSRange(location: 0, length: 7), 7), // Entire string
("A\r\nB\nC\r", NSRange(location: 0, length: 6), 4) // Mixed line breaks
]
)
func replaceText(_ testItem: (String, NSRange, Int)) throws { // swiftlint:disable:this large_tuple
let (replaceText, replaceRange, lineCount) = testItem
textStorage.replaceCharacters(in: replaceRange, with: replaceText)
#expect(layoutManager.lineCount == lineCount)
#expect(layoutManager.lineStorage.length == textStorage.length)
layoutManager.lineStorage.validateInternalState()
}
/// This ensures that getting line rect info does not invalidate layout. The issue was previously caused by a
/// call to ``TextLayoutManager/preparePositionForDisplay``.
@Test
func getRectsDoesNotRemoveLayoutInfo() {
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
let lineFragmentIDs = Set(
layoutManager.lineStorage
.linesInRange(NSRange(location: 0, length: 7))
.flatMap(\.data.lineFragments)
.map(\.data.id)
)
_ = layoutManager.rectsFor(range: NSRange(start: 0, end: 7))
#expect(
layoutManager.lineStorage.linesInRange(NSRange(location: 0, length: 7)).allSatisfy({ position in
!position.data.lineFragments.isEmpty
})
)
let afterLineFragmentIDs = Set(
layoutManager.lineStorage
.linesInRange(NSRange(location: 0, length: 7))
.flatMap(\.data.lineFragments)
.map(\.data.id)
)
#expect(lineFragmentIDs == afterLineFragmentIDs, "Line fragments were invalidated by `rectsFor(range:)` call.")
layoutManager.lineStorage.validateInternalState()
}
/// It's easy to iterate through lines by taking the last line's range, and adding one to the end of the range.
/// However, that will always skip lines that are empty, but represent a line. This test ensures that when we
/// iterate over a range, we'll always find those empty lines.
///
/// Related implementation: ``TextLayoutManager/Iterator``
@Test
func yPositionIteratorDoesNotSkipEmptyLines() {
// Layout manager keeps 1-length lines at the 2nd and 4th lines.
textStorage.mutableString.setString("A\n\nB\n\nC")
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
var lineIndexes: [Int] = []
for line in layoutManager.linesStartingAt(0.0, until: 1000.0) {
lineIndexes.append(line.index)
}
var lastLineIndex: Int?
for lineIndex in lineIndexes {
if let lastIndex = lastLineIndex {
#expect(lineIndex - 1 == lastIndex, "Skipped an index when iterating.")
} else {
#expect(lineIndex == 0, "First index was not 0")
}
lastLineIndex = lineIndex
}
}
/// See comment for `yPositionIteratorDoesNotSkipEmptyLines`.
@Test
func rangeIteratorDoesNotSkipEmptyLines() {
// Layout manager keeps 1-length lines at the 2nd and 4th lines.
textStorage.mutableString.setString("A\n\nB\n\nC")
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
var lineIndexes: [Int] = []
for line in layoutManager.linesInRange(textView.documentRange) {
lineIndexes.append(line.index)
}
var lastLineIndex: Int?
for lineIndex in lineIndexes {
if let lastIndex = lastLineIndex {
#expect(lineIndex - 1 == lastIndex, "Skipped an index when iterating.")
} else {
#expect(lineIndex == 0, "First index was not 0")
}
lastLineIndex = lineIndex
}
}
@Test
func afterLayoutDoesntNeedLayout() {
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
#expect(layoutManager.needsLayout == false)
}
/// Invalidating a range shouldn't cause a layout on any other lines next layout pass.
/// Note that this is correct behavior, and edits that add or remove lines will trigger another heuristic.
/// See `editsWithNewlinesForceLayoutGoingDownScreen`
@Test
func invalidatingRangeLaysOutLines() {
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
let lineIds = Set(layoutManager.linesInRange(NSRange(start: 2, end: 4)).map { $0.data.id })
layoutManager.invalidateLayoutForRange(NSRange(start: 2, end: 4))
#expect(layoutManager.needsLayout == false) // No forced layout
#expect(
layoutManager
.linesInRange(NSRange(start: 2, end: 4))
.allSatisfy({ $0.data.needsLayout(maxWidth: .infinity) })
)
let invalidatedLineIds = layoutManager.layoutLines()
#expect(
invalidatedLineIds.isSuperset(of: lineIds),
"Invalidated lines != lines that were laid out in next pass."
)
}
/// Inserting a new line should cause layout going down the rest of the screen, because the following lines
/// should have moved their position to accomodate the new line.
@Test
func editsWithNewlinesForceLayoutGoingDownScreen() {
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
textStorage.replaceCharacters(in: NSRange(start: 4, end: 4), with: "Z\n")
let expectedLineIds = Array(
layoutManager.lineStorage.linesInRange(NSRange(location: 4, length: 9))
).map { $0.data.id }
#expect(layoutManager.needsLayout == false) // No forced layout for entire view
let invalidatedLineIds = layoutManager.layoutLines()
#expect(Set(expectedLineIds) == invalidatedLineIds)
}
@Test
func rectForOffsetReturnsValueAfterEndOfDoc() throws {
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
for idx in 0..<10 {
// This should return something even after the end of the document.
#expect(layoutManager.rectForOffset(idx) != nil, "Failed to find rect for offset: \(idx)")
}
}
@Test
func textOffsetForPointReturnsValuesEverywhere() throws {
layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000))
// textOffsetAtPoint is valid *everywhere*. It should always return something.
for xPos in 0..<1000 {
for yPos in 0..<1000 {
#expect(layoutManager.textOffsetAtPoint(CGPoint(x: xPos, y: yPos)) != nil)
}
}
}
}