| 1 | import UIKit |
| 2 | |
| 3 | /// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. |
| 4 | final class ReviewDiffCodeLayout: NSObject { |
| 5 | // Measurement reuses one engine; only recently drawn rows retain a full TextKit layout. |
| 6 | private static var measurer: ReviewDiffTextLayout { |
| 7 | let key = "T3ReviewDiff.textMeasurer" |
| 8 | if let layout = Thread.current.threadDictionary[key] as? ReviewDiffTextLayout { return layout } |
| 9 | let layout = ReviewDiffTextLayout() |
| 10 | Thread.current.threadDictionary[key] = layout |
| 11 | return layout |
| 12 | } |
| 13 | private static let drawnLayouts: NSCache<ReviewDiffCodeLayout, ReviewDiffTextLayout> = { |
| 14 | let cache = NSCache<ReviewDiffCodeLayout, ReviewDiffTextLayout>() |
| 15 | cache.countLimit = 128 |
| 16 | return cache |
| 17 | }() |
| 18 | let text: String |
| 19 | let starts: [Int] |
| 20 | let lineHeight: CGFloat |
| 21 | let firstLineHeight: CGFloat |
| 22 | let extraHeight: CGFloat |
| 23 | private let font: UIFont |
| 24 | private let width: CGFloat |
| 25 | private let characterWidth: CGFloat |
| 26 | let usesNativeLayout: Bool |
| 27 | |
| 28 | init(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { |
| 29 | self.text = text |
| 30 | self.font = font |
| 31 | self.width = width |
| 32 | self.characterWidth = characterWidth |
| 33 | lineHeight = ceil(font.lineHeight) |
| 34 | if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) { |
| 35 | let columns = max(1, Int(width / characterWidth)) |
| 36 | starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns)) |
| 37 | firstLineHeight = font.lineHeight |
| 38 | extraHeight = CGFloat(starts.count - 1) * lineHeight |
| 39 | usesNativeLayout = false |
| 40 | } else { |
| 41 | let layout = Self.measurer |
| 42 | layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) |
| 43 | let manager = layout.manager |
| 44 | let container = layout.container |
| 45 | usesNativeLayout = true |
| 46 | starts = [0] |
| 47 | firstLineHeight = manager.numberOfGlyphs > 0 |
| 48 | ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight |
| 49 | extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | private func nativeLayout() -> ReviewDiffTextLayout { |
| 54 | if let cached = Self.drawnLayouts.object(forKey: self) { return cached } |
| 55 | let layout = ReviewDiffTextLayout() |
| 56 | layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) |
| 57 | Self.drawnLayouts.setObject(layout, forKey: self) |
| 58 | return layout |
| 59 | } |
| 60 | |
| 61 | /// Only colors change when syntax tokens arrive; the measured text and font stay intact. |
| 62 | func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) { |
| 63 | guard usesNativeLayout else { return } |
| 64 | let layout = nativeLayout() |
| 65 | guard layout.decorationVersion != version else { return } |
| 66 | let storage = layout.storage |
| 67 | let fullRange = NSRange(location: 0, length: storage.length) |
| 68 | storage.beginEditing() |
| 69 | storage.removeAttribute(.foregroundColor, range: fullRange) |
| 70 | storage.removeAttribute(.backgroundColor, range: fullRange) |
| 71 | text.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: text.length)) { value, range, _ in |
| 72 | let intersection = NSIntersectionRange(range, fullRange) |
| 73 | if let value, intersection.length > 0 { |
| 74 | storage.addAttribute(.foregroundColor, value: value, range: intersection) |
| 75 | } |
| 76 | } |
| 77 | for range in highlights { |
| 78 | let intersection = NSIntersectionRange(range, fullRange) |
| 79 | if intersection.length > 0 { |
| 80 | storage.addAttribute(.backgroundColor, value: color, range: intersection) |
| 81 | } |
| 82 | } |
| 83 | storage.endEditing() |
| 84 | layout.decorationVersion = version |
| 85 | } |
| 86 | |
| 87 | func draw(at origin: CGPoint, clip: CGRect) { |
| 88 | guard usesNativeLayout else { return } |
| 89 | let layout = nativeLayout() |
| 90 | let manager = layout.manager |
| 91 | let container = layout.container |
| 92 | let visible = clip.offsetBy(dx: -origin.x, dy: -origin.y) |
| 93 | let range = manager.glyphRange(forBoundingRect: visible, in: container) |
| 94 | manager.drawBackground(forGlyphRange: range, at: origin) |
| 95 | manager.drawGlyphs(forGlyphRange: range, at: origin) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | private final class ReviewDiffTextLayout { |
| 100 | let storage = NSTextStorage() |
| 101 | let manager = NSLayoutManager() |
| 102 | let container = NSTextContainer(size: .zero) |
| 103 | var decorationVersion = -1 |
| 104 | |
| 105 | init() { |
| 106 | container.lineFragmentPadding = 0 |
| 107 | container.lineBreakMode = .byCharWrapping |
| 108 | manager.addTextContainer(container) |
| 109 | storage.addLayoutManager(manager) |
| 110 | } |
| 111 | |
| 112 | func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { |
| 113 | let paragraph = NSMutableParagraphStyle() |
| 114 | paragraph.lineBreakMode = .byCharWrapping |
| 115 | paragraph.tabStops = [] |
| 116 | paragraph.defaultTabInterval = characterWidth * 4 |
| 117 | container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude) |
| 118 | storage.setAttributedString(NSAttributedString(string: text, attributes: [ |
| 119 | .font: font, .ligature: 0, .paragraphStyle: paragraph, |
| 120 | ])) |
| 121 | manager.ensureLayout(for: container) |
| 122 | } |
| 123 | } |