1import UIKit
2
3/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights.
4final 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
99private 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}