From 353b41896cc4d1f45bab289f872fba11f3eee17e Mon Sep 17 00:00:00 2001 From: Jeremy Schonfeld Date: Mon, 19 May 2025 11:00:44 -0700 Subject: [PATCH] Character-only mutations should not overwrite paragraph-bound attributes in paragraph following mutation --- .../AttributedString+CharacterView.swift | 2 +- .../AttributedString+Guts.swift | 7 ++-- .../AttributedString+UnicodeScalarView.swift | 2 +- ...dStringAttributeConstrainingBehavior.swift | 36 ++++++++++++++----- ...butedStringConstrainingBehaviorTests.swift | 6 +++- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/Sources/FoundationEssentials/AttributedString/AttributedString+CharacterView.swift b/Sources/FoundationEssentials/AttributedString/AttributedString+CharacterView.swift index 866c802f3..83fe52f46 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedString+CharacterView.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedString+CharacterView.swift @@ -283,7 +283,7 @@ extension AttributedString.CharacterView: RangeReplaceableCollection { _guts.runs.replaceUTF8Subrange(utf8Range, with: CollectionOfOne(run)) // Invalidate attributes surrounding the affected range. (Phase 2) - _guts._finalizeStringMutation(state) + _guts._finalizeStringMutation(state, type: .characters) } public mutating func replaceSubrange( diff --git a/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift b/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift index 163215139..719dad741 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedString+Guts.swift @@ -457,7 +457,8 @@ extension AttributedString.Guts { } func _finalizeStringMutation( - _ state: (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range) + _ state: (mutationStartUTF8Offset: Int, isInsertion: Bool, oldUTF8Count: Int, invalidationRange: Range), + type: _MutationType ) { let utf8Delta = self.string.utf8.count - state.oldUTF8Count self._finalizeTrackedIndicesUpdate(mutationStartOffset: state.mutationStartUTF8Offset, isInsertion: state.isInsertion, utf8LengthDelta: utf8Delta) @@ -465,7 +466,7 @@ extension AttributedString.Guts { let upper = state.invalidationRange.upperBound + utf8Delta self.enforceAttributeConstraintsAfterMutation( in: lower ..< upper, - type: .attributesAndCharacters) + type: type) } func replaceSubrange( @@ -493,7 +494,7 @@ extension AttributedString.Guts { let state = _prepareStringMutation(in: range) self.string.unicodeScalars.replaceSubrange(range, with: replacementScalars) self.runs.replaceUTF8Subrange(utf8TargetRange, with: replacementRuns) - _finalizeStringMutation(state) + _finalizeStringMutation(state, type: .attributesAndCharacters) } else { self.runs.replaceUTF8Subrange(utf8TargetRange, with: replacementRuns) self.enforceAttributeConstraintsAfterMutation(in: range._utf8OffsetRange, type: .attributes) diff --git a/Sources/FoundationEssentials/AttributedString/AttributedString+UnicodeScalarView.swift b/Sources/FoundationEssentials/AttributedString/AttributedString+UnicodeScalarView.swift index c7006ef89..81d250c5d 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedString+UnicodeScalarView.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedString+UnicodeScalarView.swift @@ -264,7 +264,7 @@ extension AttributedString.UnicodeScalarView: RangeReplaceableCollection { _guts.runs.replaceUTF8Subrange(utf8Range, with: CollectionOfOne(run)) // Invalidate attributes surrounding the affected range. (Phase 2) - _guts._finalizeStringMutation(state) + _guts._finalizeStringMutation(state, type: .characters) } public mutating func replaceSubrange( diff --git a/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeConstrainingBehavior.swift b/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeConstrainingBehavior.swift index edf767c00..8fbe6161f 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeConstrainingBehavior.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeConstrainingBehavior.swift @@ -172,6 +172,7 @@ extension AttributedString.Guts { enum _MutationType { case attributes case attributesAndCharacters + case characters } /// Removes full runs of any attributes that have declared an @@ -291,7 +292,7 @@ extension AttributedString.Guts { ).updateEach { attributes, _, modified in modified = attributes.matchStyle(of: paragraphStyle, for: .paragraph) } - } else if type == .attributesAndCharacters { + } else { // If any character mutations took place, we apply the constrained styles from the start of each paragraph to the remainder of the paragraph // The mutation range itself is already fixed-up, so we just need to correct the starting and ending paragraphs @@ -324,8 +325,18 @@ extension AttributedString.Guts { (startParagraph?.upperBound ?? 0) < utf8Range.upperBound, _needsParagraphFixing(from: utf8Range.upperBound - 1, to: utf8Range.upperBound) { - let r = _paragraphExtending(from: string.index(before: strRange.upperBound)) - endParagraph = r._utf8OffsetRange + let justInsideIndex = string.index(before: strRange.upperBound) + let r = if type == .characters { + // If a character-only mutation, since we apply to the start of the paragraph we need the full real paragraph range + _paragraph(in: Range(uncheckedBounds: (justInsideIndex, justInsideIndex))) + } else { + // If an attribute + character mutation, we don't need the real start of the paragraph + _paragraphExtending(from: justInsideIndex) + } + // If the ending paragraph starts before the mutation we shouldn't store it here as it will incorrectly overwrite the expansion from startParagraph + if r.lowerBound >= strRange.lowerBound { + endParagraph = r._utf8OffsetRange + } } } @@ -336,12 +347,21 @@ extension AttributedString.Guts { from: startParagraph.lowerBound, to: utf8Range.lowerBound ..< startParagraph.upperBound) } - // If the end paragraph extends beyond the mutation, fixup the range outside the mutation + // If the end paragraph extends beyond the mutation, fixup the paragraph if let endParagraph, endParagraph.upperBound > utf8Range.upperBound { - _applyStyle( - type: .paragraph, - from: endParagraph.lowerBound, - to: utf8Range.upperBound ..< endParagraph.upperBound) + if type == .attributesAndCharacters { + // If an attribute + characters mutation, the newly applied attributes expand to cover the remainder of the paragraph + _applyStyle( + type: .paragraph, + from: endParagraph.lowerBound, + to: utf8Range.upperBound ..< endParagraph.upperBound) + } else { + // If a character-only mutation, the attributes from the remainder of the paragraph expand to cover the newly inserted text + _applyStyle( + type: .paragraph, + from: utf8Range.upperBound, + to: endParagraph.lowerBound ..< utf8Range.upperBound) + } } } } diff --git a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringConstrainingBehaviorTests.swift b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringConstrainingBehaviorTests.swift index 24698e8ed..eaec6171f 100644 --- a/Tests/FoundationEssentialsTests/AttributedString/AttributedStringConstrainingBehaviorTests.swift +++ b/Tests/FoundationEssentialsTests/AttributedString/AttributedStringConstrainingBehaviorTests.swift @@ -223,6 +223,10 @@ class TestAttributedStringConstrainingBehavior: XCTestCase { result.characters.insert(contentsOf: "Test", at: result.index(result.startIndex, offsetByCharacters: 2)) verify(string: result, matches: [("HeTestllo, world\n", 1), ("Next Paragraph", 2)], for: \.testParagraphConstrained) + result = str + result.characters.insert(contentsOf: "Test", at: result.index(result.startIndex, offsetByCharacters: 13)) + verify(string: result, matches: [("Hello, world\n", 1), ("TestNext Paragraph", 2)], for: \.testParagraphConstrained) + result = str result.characters.append(contentsOf: "Test") verify(string: result, matches: [("Hello, world\n", 1), ("Next ParagraphTest", 2)], for: \.testParagraphConstrained) @@ -257,7 +261,7 @@ class TestAttributedStringConstrainingBehavior: XCTestCase { result = str result.characters.replaceSubrange(result.index(result.startIndex, offsetByCharacters: 8) ..< result.index(result.startIndex, offsetByCharacters: 15), with: "Test\nReplacement") - verify(string: result, matches: [("Hello, wTest\n", 1), ("Replacementxt Paragraph", 1)], for: \.testParagraphConstrained) + verify(string: result, matches: [("Hello, wTest\n", 1), ("Replacementxt Paragraph", 2)], for: \.testParagraphConstrained) } func testParagraphAttributedTextMutation() {