Skip to content

Commit 353b418

Browse files
committed
Character-only mutations should not overwrite paragraph-bound attributes in paragraph following mutation
1 parent 7002497 commit 353b418

File tree

5 files changed

+39
-14
lines changed

5 files changed

+39
-14
lines changed

Sources/FoundationEssentials/AttributedString/AttributedString+CharacterView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ extension AttributedString.CharacterView: RangeReplaceableCollection {
283283
_guts.runs.replaceUTF8Subrange(utf8Range, with: CollectionOfOne(run))
284284

285285
// Invalidate attributes surrounding the affected range. (Phase 2)
286-
_guts._finalizeStringMutation(state)
286+
_guts._finalizeStringMutation(state, type: .characters)
287287
}
288288

289289
public mutating func replaceSubrange(

Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,15 +457,16 @@ extension AttributedString.Guts {
457457
}
458458

459459
func _finalizeStringMutation(
460-
_ state: (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range<Int>)
460+
_ state: (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range<Int>),
461+
type: _MutationType
461462
) {
462463
let utf8Delta = self.string.utf8.count - state.oldUTF8Count
463464
self._finalizeTrackedIndicesUpdate(mutationStartOffset: state.mutationStartUTF8Offset, isInsertion: state.isInsertion, utf8LengthDelta: utf8Delta)
464465
let lower = state.invalidationRange.lowerBound
465466
let upper = state.invalidationRange.upperBound + utf8Delta
466467
self.enforceAttributeConstraintsAfterMutation(
467468
in: lower ..< upper,
468-
type: .attributesAndCharacters)
469+
type: type)
469470
}
470471

471472
func replaceSubrange(
@@ -493,7 +494,7 @@ extension AttributedString.Guts {
493494
let state = _prepareStringMutation(in: range)
494495
self.string.unicodeScalars.replaceSubrange(range, with: replacementScalars)
495496
self.runs.replaceUTF8Subrange(utf8TargetRange, with: replacementRuns)
496-
_finalizeStringMutation(state)
497+
_finalizeStringMutation(state, type: .attributesAndCharacters)
497498
} else {
498499
self.runs.replaceUTF8Subrange(utf8TargetRange, with: replacementRuns)
499500
self.enforceAttributeConstraintsAfterMutation(in: range._utf8OffsetRange, type: .attributes)

Sources/FoundationEssentials/AttributedString/AttributedString+UnicodeScalarView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ extension AttributedString.UnicodeScalarView: RangeReplaceableCollection {
264264
_guts.runs.replaceUTF8Subrange(utf8Range, with: CollectionOfOne(run))
265265

266266
// Invalidate attributes surrounding the affected range. (Phase 2)
267-
_guts._finalizeStringMutation(state)
267+
_guts._finalizeStringMutation(state, type: .characters)
268268
}
269269

270270
public mutating func replaceSubrange(

Sources/FoundationEssentials/AttributedString/AttributedStringAttributeConstrainingBehavior.swift

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ extension AttributedString.Guts {
172172
enum _MutationType {
173173
case attributes
174174
case attributesAndCharacters
175+
case characters
175176
}
176177

177178
/// Removes full runs of any attributes that have declared an
@@ -291,7 +292,7 @@ extension AttributedString.Guts {
291292
).updateEach { attributes, _, modified in
292293
modified = attributes.matchStyle(of: paragraphStyle, for: .paragraph)
293294
}
294-
} else if type == .attributesAndCharacters {
295+
} else {
295296
// If any character mutations took place, we apply the constrained styles from the start of each paragraph to the remainder of the paragraph
296297
// The mutation range itself is already fixed-up, so we just need to correct the starting and ending paragraphs
297298

@@ -324,8 +325,18 @@ extension AttributedString.Guts {
324325
(startParagraph?.upperBound ?? 0) < utf8Range.upperBound,
325326
_needsParagraphFixing(from: utf8Range.upperBound - 1, to: utf8Range.upperBound)
326327
{
327-
let r = _paragraphExtending(from: string.index(before: strRange.upperBound))
328-
endParagraph = r._utf8OffsetRange
328+
let justInsideIndex = string.index(before: strRange.upperBound)
329+
let r = if type == .characters {
330+
// If a character-only mutation, since we apply to the start of the paragraph we need the full real paragraph range
331+
_paragraph(in: Range(uncheckedBounds: (justInsideIndex, justInsideIndex)))
332+
} else {
333+
// If an attribute + character mutation, we don't need the real start of the paragraph
334+
_paragraphExtending(from: justInsideIndex)
335+
}
336+
// If the ending paragraph starts before the mutation we shouldn't store it here as it will incorrectly overwrite the expansion from startParagraph
337+
if r.lowerBound >= strRange.lowerBound {
338+
endParagraph = r._utf8OffsetRange
339+
}
329340
}
330341
}
331342

@@ -336,12 +347,21 @@ extension AttributedString.Guts {
336347
from: startParagraph.lowerBound,
337348
to: utf8Range.lowerBound ..< startParagraph.upperBound)
338349
}
339-
// If the end paragraph extends beyond the mutation, fixup the range outside the mutation
350+
// If the end paragraph extends beyond the mutation, fixup the paragraph
340351
if let endParagraph, endParagraph.upperBound > utf8Range.upperBound {
341-
_applyStyle(
342-
type: .paragraph,
343-
from: endParagraph.lowerBound,
344-
to: utf8Range.upperBound ..< endParagraph.upperBound)
352+
if type == .attributesAndCharacters {
353+
// If an attribute + characters mutation, the newly applied attributes expand to cover the remainder of the paragraph
354+
_applyStyle(
355+
type: .paragraph,
356+
from: endParagraph.lowerBound,
357+
to: utf8Range.upperBound ..< endParagraph.upperBound)
358+
} else {
359+
// If a character-only mutation, the attributes from the remainder of the paragraph expand to cover the newly inserted text
360+
_applyStyle(
361+
type: .paragraph,
362+
from: utf8Range.upperBound,
363+
to: endParagraph.lowerBound ..< utf8Range.upperBound)
364+
}
345365
}
346366
}
347367
}

Tests/FoundationEssentialsTests/AttributedString/AttributedStringConstrainingBehaviorTests.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ class TestAttributedStringConstrainingBehavior: XCTestCase {
223223
result.characters.insert(contentsOf: "Test", at: result.index(result.startIndex, offsetByCharacters: 2))
224224
verify(string: result, matches: [("HeTestllo, world\n", 1), ("Next Paragraph", 2)], for: \.testParagraphConstrained)
225225

226+
result = str
227+
result.characters.insert(contentsOf: "Test", at: result.index(result.startIndex, offsetByCharacters: 13))
228+
verify(string: result, matches: [("Hello, world\n", 1), ("TestNext Paragraph", 2)], for: \.testParagraphConstrained)
229+
226230
result = str
227231
result.characters.append(contentsOf: "Test")
228232
verify(string: result, matches: [("Hello, world\n", 1), ("Next ParagraphTest", 2)], for: \.testParagraphConstrained)
@@ -257,7 +261,7 @@ class TestAttributedStringConstrainingBehavior: XCTestCase {
257261

258262
result = str
259263
result.characters.replaceSubrange(result.index(result.startIndex, offsetByCharacters: 8) ..< result.index(result.startIndex, offsetByCharacters: 15), with: "Test\nReplacement")
260-
verify(string: result, matches: [("Hello, wTest\n", 1), ("Replacementxt Paragraph", 1)], for: \.testParagraphConstrained)
264+
verify(string: result, matches: [("Hello, wTest\n", 1), ("Replacementxt Paragraph", 2)], for: \.testParagraphConstrained)
261265
}
262266

263267
func testParagraphAttributedTextMutation() {

0 commit comments

Comments
 (0)