Skip to content

Commit bd9fbee

Browse files
whitneylandleegohd
authored andcommitted
Fix stale text position handling
1 parent 01b5187 commit bd9fbee

4 files changed

Lines changed: 57 additions & 3 deletions

File tree

Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Geometry.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@
124124
}
125125

126126
func isPositionAtBlockBoundary(_ position: TextPosition) -> Bool {
127+
guard contains(position) else {
128+
return false
129+
}
130+
127131
if position
128132
== TextPosition(
129133
indexPath: .init(layout: position.indexPath.layout),

Sources/Textual/Internal/TextInteraction/Shared/TextLayout/TextLayoutCollection+Positioning.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
}
3030

3131
func position(from position: TextPosition, offset: Int) -> TextPosition? {
32-
let from = characterIndex(at: position)
32+
guard let from = characterIndex(at: position) else {
33+
return nil
34+
}
3335
let target = from + offset
3436

3537
guard (0...stringLength).contains(target) else {
@@ -59,13 +61,38 @@
5961
return self.position(at: layout, localCharacterIndex: localTarget)
6062
}
6163

62-
func characterIndex(at position: TextPosition) -> Int {
64+
func characterIndex(at position: TextPosition) -> Int? {
65+
guard contains(position) else {
66+
return nil
67+
}
6368
let base = layouts.prefix(position.indexPath.layout)
6469
.map(\.attributedString.length)
6570
.reduce(0, +)
6671
return base + localCharacterIndex(at: position)
6772
}
6873

74+
func contains(_ position: TextPosition) -> Bool {
75+
let indexPath = position.indexPath
76+
guard
77+
indexPath.count == 4,
78+
layouts.indices.contains(indexPath.layout)
79+
else {
80+
return false
81+
}
82+
83+
let layout = layouts[indexPath.layout]
84+
guard layout.lines.indices.contains(indexPath.line) else {
85+
return false
86+
}
87+
88+
let line = layout.lines[indexPath.line]
89+
guard line.runs.indices.contains(indexPath.run) else {
90+
return false
91+
}
92+
93+
return line.runs[indexPath.run].slices.indices.contains(indexPath.runSlice)
94+
}
95+
6996
func localCharacterIndex(at position: TextPosition) -> Int {
7097
let range = localCharacterRange(at: position.indexPath)
7198
switch position.affinity {

Sources/Textual/Internal/TextInteraction/Shared/TextSelectionModel.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,13 @@
111111
}
112112

113113
func offset(from: TextPosition, to: TextPosition) -> Int {
114-
layoutCollection.characterIndex(at: to) - layoutCollection.characterIndex(at: from)
114+
guard
115+
let fromIndex = layoutCollection.characterIndex(at: from),
116+
let toIndex = layoutCollection.characterIndex(at: to)
117+
else {
118+
return 0
119+
}
120+
return toIndex - fromIndex
115121
}
116122

117123
func firstRect(for range: TextRange) -> CGRect {

Tests/TextualTests/Internal/TextInteraction/TextSelectionModelTests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,23 @@
198198
#expect(offset == -38)
199199
}
200200

201+
@Test
202+
func stalePositionsAfterLayoutBecomesEmpty() throws {
203+
// given
204+
let model = try TextSelectionModel(fixtureName: "two-paragraphs-bidi")
205+
let staleStart = model.startPosition
206+
let staleEnd = model.endPosition
207+
208+
// when
209+
model.setLayoutCollection(EmptyTextLayoutCollection())
210+
211+
// then
212+
#expect(model.position(from: staleStart, offset: 0) == nil)
213+
#expect(model.offset(from: staleStart, to: staleEnd) == 0)
214+
#expect(model.isPositionAtBlockBoundary(staleStart) == false)
215+
#expect(model.isPositionAtBlockBoundary(staleEnd) == false)
216+
}
217+
201218
@Test
202219
func firstRectCollapsedRange() throws {
203220
// given

0 commit comments

Comments
 (0)