diff --git a/Proton/Sources/Swift/Helpers/NSAttributedString+Content.swift b/Proton/Sources/Swift/Helpers/NSAttributedString+Content.swift index 80877c6a..0a7a3394 100644 --- a/Proton/Sources/Swift/Helpers/NSAttributedString+Content.swift +++ b/Proton/Sources/Swift/Helpers/NSAttributedString+Content.swift @@ -54,6 +54,40 @@ public extension NSAttributedString { } return string.makeNSRange(from: range) } + + /// Enumerates over continuous ranges of text based on the presence or absence of a specified attribute. + /// - Parameters: + /// - attributeName: The name of the attribute to check for presence or absence. + /// - range: The range within the attributed string to enumerate. Defaults to the entire string. + /// - using: The block to apply to continuous ranges, indicating whether the attribute was present or absent for the range. + func enumerateContinuousRangesByAttribute(_ attributeName: NSAttributedString.Key, in range: NSRange? = nil, using block: (_ isPresent: Bool, _ range: NSRange) -> Void) { + let enumerationRange = range ?? NSRange(location: 0, length: self.length) + var lastRange: NSRange? = nil + var isAttributePresentInLastRange = false + + self.enumerateAttributes(in: enumerationRange, options: []) { attributes, currentRange, _ in + let isAttributePresent = attributes[attributeName] != nil + if let lastRangeUnwrapped = lastRange { + if isAttributePresentInLastRange != isAttributePresent { + // Process the last range if the attribute presence state changes + block(isAttributePresentInLastRange, lastRangeUnwrapped) + lastRange = currentRange + } else { + // Extend the last range efficiently if the state hasn't changed + lastRange = NSRange(location: lastRangeUnwrapped.location, length: NSMaxRange(currentRange) - lastRangeUnwrapped.location) + } + } else { + // Initialize with the first range + lastRange = currentRange + } + isAttributePresentInLastRange = isAttributePresent + } + + // Process the final range after enumeration + if let lastRangeUnwrapped = lastRange { + block(isAttributePresentInLastRange, lastRangeUnwrapped) + } + } } extension NSAttributedString { diff --git a/Proton/Tests/ExtensionTests/NSAttributedStringExtensionTests.swift b/Proton/Tests/ExtensionTests/NSAttributedStringExtensionTests.swift index 5b1668ab..14392682 100644 --- a/Proton/Tests/ExtensionTests/NSAttributedStringExtensionTests.swift +++ b/Proton/Tests/ExtensionTests/NSAttributedStringExtensionTests.swift @@ -218,4 +218,25 @@ class NSAttributedStringExtensionTests: XCTestCase { let range = text.reverseRange(of: "isx", startingLocation: text.length - 1) XCTAssertNil(range) } + + func testEnumeratesIgnoringValue() { + let text = NSMutableAttributedString(string: "This is a test string", attributes: [.inlineContentType: "a"]) + text.append(NSAttributedString(string: " Some more text", attributes: [.inlineContentType: "b"])) + text.append(NSAttributedString(string: " And more text", attributes: [.inlineContentType: "c"])) + text.append(NSAttributedString(string: "Not with attribute", attributes: [:])) + text.append(NSAttributedString(string: "Again with attribute", attributes: [.inlineContentType: "c"])) + + let expected = [ + ("This is a test string Some more text And more text", true), + ("Not with attribute", false), + ("Again with attribute", true) + ] + var counter = 0 + text.enumerateContinuousRangesByAttribute(.inlineContentType) { isPresent, range in + XCTAssertEqual(expected[counter].0, text.substring(from: range)) + XCTAssertEqual(expected[counter].1, isPresent) + counter += 1 + } + XCTAssertEqual(counter, 3) + } }