//===----------------------------------------------------------------------===// // // This source file is part of the Swift.org open source project // // Copyright (c) 2020-2023 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // // See https://swift.org/LICENSE.txt for license information // See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // //===----------------------------------------------------------------------===// @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension AttributedString { public struct Runs : Sendable { internal typealias _InternalRun = AttributedString._InternalRun internal typealias _AttributeStorage = AttributedString._AttributeStorage internal typealias AttributeRunBoundaries = AttributedString.AttributeRunBoundaries internal var _guts: Guts internal var _range: Range internal var _runRange: Range internal init(_ g: Guts, _ r: Range) { _guts = g _range = r let startRun = _guts.indexOfRun(at: _range.lowerBound) let endRun: AttributedString.Runs.Index if _range.upperBound == _guts.endIndex { endRun = .init(rangeIndex: _guts.runs.count) } else if _range.upperBound == _guts.startIndex { endRun = .init(rangeIndex: 0) } else { let prev = _guts.utf8Index(before: _range.upperBound) endRun = .init(rangeIndex: _guts.indexOfRun(at: prev).rangeIndex + 1) } self._runRange = startRun ..< endRun } } public var runs: Runs { Runs(_guts, _guts.startIndex ..< _guts.endIndex) } } @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension AttributedString.Runs: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { // Note: Unlike AttributedString itself, this is comparing run lengths without normalizing // the underlying characters. // // I.e., the runs of two equal attribute strings may or may not compare equal. let lhsSlice = lhs._guts.runs[lhs._runRange._offsetRange] let rhsSlice = rhs._guts.runs[rhs._runRange._offsetRange] // If there are different numbers of runs, they aren't equal guard lhsSlice.count == rhsSlice.count else { return false } let runCount = lhsSlice.count // Empty slices are always equal guard runCount > 0 else { return true } // Compare the first run (clamping their ranges) since we know each has at least one run let first1 = lhs._guts.run(at: lhs.startIndex, clampedBy: lhs._range) let first2 = rhs._guts.run(at: rhs.startIndex, clampedBy: rhs._range) if first1 != first2 { return false } // Compare all inner runs if they exist without needing to clamp ranges if runCount > 2 { let slice1 = lhsSlice[lhsSlice.startIndex + 1 ..< lhsSlice.endIndex - 1] let slice2 = rhsSlice[rhsSlice.startIndex + 1 ..< rhsSlice.endIndex - 1] if !slice1.elementsEqual(slice2) { return false } } // If there are more than one run (so we didn't already check this as the first run), check the last run (clamping its range) if runCount > 1 { let i1 = Index(rangeIndex: lhs._runRange.upperBound.rangeIndex - 1) let i2 = Index(rangeIndex: rhs._runRange.upperBound.rangeIndex - 1) let last1 = lhs._guts.run(at: i1, clampedBy: lhs._range) let last2 = rhs._guts.run(at: i2, clampedBy: rhs._range) if last1 != last2 { return false } } return true } } @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension AttributedString.Runs: CustomStringConvertible { public var description: String { AttributedSubstring(_guts, _range).description } } @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension AttributedString.Runs: BidirectionalCollection { public struct Index: Comparable, Strideable, Sendable { internal let rangeIndex: Int public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rangeIndex < rhs.rangeIndex } public func distance(to other: Self) -> Int { other.rangeIndex - rangeIndex } public func advanced(by n: Int) -> Self { Index(rangeIndex: rangeIndex + n) } } public typealias Element = Run public func index(before i: Index) -> Index { Index(rangeIndex: i.rangeIndex - 1) } public func index(after i: Index) -> Index { Index(rangeIndex: i.rangeIndex + 1) } public var startIndex: Index { _runRange.lowerBound } public var endIndex: Index { _runRange.upperBound } public subscript(position: Index) -> Run { return _guts.run(at: position, clampedBy: _range) } internal subscript(internal position: Index) -> _InternalRun { return _guts.runs[position.rangeIndex] } public subscript(position: AttributedString.Index) -> Run { let (internalRun, range) = _guts.run(at: position, clampedBy: _range) return Run(_internal: internalRun, range, _guts) } } @available(macOS 12, iOS 15, tvOS 15, watchOS 8, *) extension AttributedString.Runs { // ???: public? internal func indexOfRun(at position: AttributedString.Index) -> Index { return _guts.indexOfRun(at: position) } internal func _firstOfMatchingRuns( with i: Index, comparing attributeNames: [String] ) -> Index { precondition(!attributeNames.isEmpty) let attributes = self[internal: i].attributes var j = i while j > startIndex { let prev = index(before: j) let run = self[internal: prev] if !attributes.isEqual(to: run.attributes, comparing: attributeNames) { return j } j = prev } return j } internal func _lastOfMatchingRuns( with i: Index, comparing attributeNames: [String] ) -> Index { precondition(!attributeNames.isEmpty) precondition(i < endIndex) let attributes = self[internal: i].attributes var j = i while true { let next = index(after: j) if next == endIndex { break } let run = self[internal: next] if !attributes.isEqual(to: run.attributes, comparing: attributeNames) { return j } j = next } return j } private func firstConstraintBreak( in range: Range, with constraints: [AttributeRunBoundaries] ) -> AttributedString.Index { guard !constraints.isEmpty, !range.isEmpty else { return range.upperBound } var r = range._bstringRange if constraints.contains(.paragraph), let firstBreak = _guts.string.findFirstParagraphBoundary(in: r) { r = r.lowerBound ..< firstBreak } if constraints._containsCharacterConstraint { // Note: we need to slice runs on matching characters even if they don't carry // the attributes we're looking for. let characters: [Character] = constraints.compactMap { $0._constrainedCharacter } if let firstBreak = _guts.string.findFirstCharacterBoundary(for: characters, in: r) { r = r.lowerBound ..< firstBreak } } return .init(r.upperBound) } private func lastConstraintBreak( in range: Range, with constraints: [AttributeRunBoundaries] ) -> AttributedString.Index { guard !constraints.isEmpty, !range.isEmpty else { return range.lowerBound } var r = range._bstringRange if constraints.contains(.paragraph), let lastBreak = _guts.string.findLastParagraphBoundary(in: r) { r = lastBreak ..< r.upperBound } if constraints._containsCharacterConstraint { // Note: we need to slice runs on matching characters even if they don't carry // the attributes we're looking for. let characters: [Character] = constraints.compactMap { $0._constrainedCharacter } if let lastBreak = _guts.string.findLastCharacterBoundary(for: characters, in: r) { r = lastBreak ..< r.upperBound } } return .init(r.lowerBound) } internal func _slicedRunBoundary( after i: AttributedString.Index, attributeNames: [String], constraints: [AttributeRunBoundaries] ) -> AttributedString.Index { precondition( _guts.utf8Offset(of: i) >= _guts.utf8Offset(of: self._range.lowerBound) && _guts.utf8Offset(of: i) < _guts.utf8Offset(of: self._range.upperBound), "AttributedString index is out of bounds") precondition(!attributeNames.isEmpty) let runIndex = indexOfRun(at: i) let endRun = _lastOfMatchingRuns(with: runIndex, comparing: attributeNames) let end = self[endRun].range.upperBound return firstConstraintBreak(in: i ..< end, with: constraints) } internal func _slicedRunBoundary( before i: AttributedString.Index, attributeNames: [String], constraints: [AttributeRunBoundaries] ) -> AttributedString.Index { precondition( _guts.utf8Offset(of: i) > _guts.utf8Offset(of: self._range.lowerBound) && _guts.utf8Offset(of: i) <= _guts.utf8Offset(of: self._range.upperBound), "AttributedString index is out of bounds") precondition(!attributeNames.isEmpty) let runIndex = indexOfRun(at: _guts.utf8Index(before: i)) let startRun = _firstOfMatchingRuns(with: runIndex, comparing: attributeNames) let start = self[startRun].range.lowerBound return lastConstraintBreak(in: start ..< i, with: constraints) } internal func _slicedRunBoundary( roundingDown i: AttributedString.Index, attributeNames: [String], constraints: [AttributeRunBoundaries] ) -> (index: AttributedString.Index, runIndex: AttributedString.Runs.Index) { precondition( _guts.utf8Offset(of: i) >= _guts.utf8Offset(of: self._range.lowerBound) && _guts.utf8Offset(of: i) <= _guts.utf8Offset(of: self._range.upperBound), "AttributedString index is out of bounds") precondition(!attributeNames.isEmpty) let runIndex = indexOfRun(at: i) if runIndex == endIndex { return (i, runIndex) } let startRun = _firstOfMatchingRuns(with: runIndex, comparing: attributeNames) let start = self[startRun].range.lowerBound let j = _guts.characterIndex(after: i) return (lastConstraintBreak(in: start ..< j, with: constraints), runIndex) } } extension _BString { func findFirstParagraphBoundary(in range: Range) -> Index? { self.utf8[range]._getBlock(for: [.findEnd], in: range.lowerBound ..< range.lowerBound).end } func findLastParagraphBoundary(in range: Range) -> Index? { guard range.upperBound > startIndex else { return nil } let lower = self.utf8Index(before: range.upperBound) return self.utf8[range]._getBlock(for: [.findStart], in: lower ..< range.upperBound).start } } extension _BString { func findFirstCharacterBoundary( for characters: [Character], in range: Range ) -> Index? { assert(range.upperBound <= endIndex) var it = makeCharacterIterator(from: range.lowerBound) guard !it.isAtEnd else { return nil } if characters.contains(it.current) { return Swift.min(range.upperBound, it.nextIndex) } while true { guard it.stepForward(), it.isBelow(range.upperBound) else { break } if characters.contains(it.current) { return it.index } } return nil } func findLastCharacterBoundary( for characters: [Character], in range: Range ) -> Index? { assert(range.upperBound <= endIndex) var it = makeCharacterIterator(from: range.upperBound) if it.stepBackward() { if it.isAbove(range.lowerBound), characters.contains(it.current) { return it.index } } while true { guard it.isAbove(range.lowerBound), it.stepBackward() else { break } if characters.contains(it.current) { return it.nextIndex } } return nil } }