| 1 | import ExpoModulesCore |
| 2 | import UIKit |
| 3 | |
| 4 | private struct ReviewDiffNativeRow: Decodable, Sendable { |
| 5 | let kind: String |
| 6 | let id: String |
| 7 | let fileId: String? |
| 8 | let filePath: String? |
| 9 | let previousPath: String? |
| 10 | let changeType: String? |
| 11 | let additions: Int? |
| 12 | let deletions: Int? |
| 13 | let text: String? |
| 14 | let content: String? |
| 15 | let change: String? |
| 16 | let oldLineNumber: Int? |
| 17 | let newLineNumber: Int? |
| 18 | let wordDiffRanges: [ReviewDiffNativeWordDiffRange]? |
| 19 | let commentText: String? |
| 20 | let commentRangeLabel: String? |
| 21 | let commentSectionTitle: String? |
| 22 | } |
| 23 | |
| 24 | private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { |
| 25 | let start: Int |
| 26 | let end: Int |
| 27 | } |
| 28 | |
| 29 | private struct ReviewDiffNativeToken: Decodable, Sendable { |
| 30 | let content: String |
| 31 | let color: String? |
| 32 | let fontStyle: Int? |
| 33 | } |
| 34 | |
| 35 | private struct ReviewDiffNativeTokenPatch: Decodable, Sendable { |
| 36 | let resetKey: String? |
| 37 | let chunkIndex: Int? |
| 38 | let tokensByRowId: [String: [ReviewDiffNativeToken]]? |
| 39 | } |
| 40 | |
| 41 | private struct ReviewDiffNativeThemePayload: Decodable { |
| 42 | let background: String? |
| 43 | let text: String? |
| 44 | let mutedText: String? |
| 45 | let headerBackground: String? |
| 46 | let border: String? |
| 47 | let hunkBackground: String? |
| 48 | let hunkText: String? |
| 49 | let addBackground: String? |
| 50 | let deleteBackground: String? |
| 51 | let addBar: String? |
| 52 | let deleteBar: String? |
| 53 | let addText: String? |
| 54 | let deleteText: String? |
| 55 | } |
| 56 | |
| 57 | private struct ReviewDiffNativeTheme { |
| 58 | let background: UIColor |
| 59 | let text: UIColor |
| 60 | let mutedText: UIColor |
| 61 | let headerBackground: UIColor |
| 62 | let border: UIColor |
| 63 | let hunkBackground: UIColor |
| 64 | let hunkText: UIColor |
| 65 | let addBackground: UIColor |
| 66 | let deleteBackground: UIColor |
| 67 | let addBar: UIColor |
| 68 | let deleteBar: UIColor |
| 69 | let addText: UIColor |
| 70 | let deleteText: UIColor |
| 71 | |
| 72 | static func resolve(_ scheme: String) -> ReviewDiffNativeTheme { |
| 73 | resolve(scheme, payload: nil) |
| 74 | } |
| 75 | |
| 76 | static func resolve( |
| 77 | _ scheme: String, |
| 78 | payload: ReviewDiffNativeThemePayload? |
| 79 | ) -> ReviewDiffNativeTheme { |
| 80 | let fallback = fallback(scheme) |
| 81 | guard let payload else { |
| 82 | return fallback |
| 83 | } |
| 84 | |
| 85 | return ReviewDiffNativeTheme( |
| 86 | background: UIColor(reviewDiffHex: payload.background) ?? fallback.background, |
| 87 | text: UIColor(reviewDiffHex: payload.text) ?? fallback.text, |
| 88 | mutedText: UIColor(reviewDiffHex: payload.mutedText) ?? fallback.mutedText, |
| 89 | headerBackground: UIColor(reviewDiffHex: payload.headerBackground) ?? fallback.headerBackground, |
| 90 | border: UIColor(reviewDiffHex: payload.border) ?? fallback.border, |
| 91 | hunkBackground: UIColor(reviewDiffHex: payload.hunkBackground) ?? fallback.hunkBackground, |
| 92 | hunkText: UIColor(reviewDiffHex: payload.hunkText) ?? fallback.hunkText, |
| 93 | addBackground: UIColor(reviewDiffHex: payload.addBackground) ?? fallback.addBackground, |
| 94 | deleteBackground: UIColor(reviewDiffHex: payload.deleteBackground) ?? fallback.deleteBackground, |
| 95 | addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar, |
| 96 | deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar, |
| 97 | addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText, |
| 98 | deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText |
| 99 | ) |
| 100 | } |
| 101 | |
| 102 | private static func fallback(_ scheme: String) -> ReviewDiffNativeTheme { |
| 103 | if scheme == "dark" { |
| 104 | return ReviewDiffNativeTheme( |
| 105 | background: UIColor(red: 0.07, green: 0.07, blue: 0.07, alpha: 1), |
| 106 | text: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1), |
| 107 | mutedText: UIColor(red: 0.52, green: 0.52, blue: 0.52, alpha: 1), |
| 108 | headerBackground: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1), |
| 109 | border: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1), |
| 110 | hunkBackground: UIColor(red: 0.03, green: 0.14, blue: 0.18, alpha: 1), |
| 111 | hunkText: UIColor(red: 0.41, green: 0.82, blue: 1.00, alpha: 1), |
| 112 | addBackground: UIColor(red: 0.02, green: 0.16, blue: 0.10, alpha: 1), |
| 113 | deleteBackground: UIColor(red: 0.18, green: 0.05, blue: 0.08, alpha: 1), |
| 114 | addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1), |
| 115 | deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1), |
| 116 | addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1), |
| 117 | deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1) |
| 118 | ) |
| 119 | } |
| 120 | |
| 121 | return ReviewDiffNativeTheme( |
| 122 | background: UIColor.white, |
| 123 | text: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1), |
| 124 | mutedText: UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1), |
| 125 | headerBackground: UIColor.white, |
| 126 | border: UIColor(red: 0.88, green: 0.88, blue: 0.90, alpha: 1), |
| 127 | hunkBackground: UIColor(red: 0.83, green: 0.92, blue: 0.98, alpha: 1), |
| 128 | hunkText: UIColor(red: 0.00, green: 0.45, blue: 0.74, alpha: 1), |
| 129 | addBackground: UIColor(red: 0.85, green: 0.94, blue: 0.92, alpha: 1), |
| 130 | deleteBackground: UIColor(red: 0.96, green: 0.85, blue: 0.90, alpha: 1), |
| 131 | addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1), |
| 132 | deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1), |
| 133 | addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1), |
| 134 | deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1) |
| 135 | ) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | private struct ReviewDiffNativeStylePayload: Decodable { |
| 140 | let wordWrap: Bool? |
| 141 | let rowHeight: Double? |
| 142 | let contentWidth: Double? |
| 143 | let changeBarWidth: Double? |
| 144 | let gutterWidth: Double? |
| 145 | let codePadding: Double? |
| 146 | let textVerticalInset: Double? |
| 147 | let fileHeaderHeight: Double? |
| 148 | let fileHeaderHorizontalMargin: Double? |
| 149 | let fileHeaderVerticalMargin: Double? |
| 150 | let fileHeaderCornerRadius: Double? |
| 151 | let fileHeaderHorizontalPadding: Double? |
| 152 | let fileHeaderPathRightPadding: Double? |
| 153 | let fileHeaderCountColumnWidth: Double? |
| 154 | let fileHeaderCountGap: Double? |
| 155 | let codeFontSize: Double? |
| 156 | let codeFontWeight: String? |
| 157 | let lineNumberFontSize: Double? |
| 158 | let lineNumberFontWeight: String? |
| 159 | let hunkFontSize: Double? |
| 160 | let hunkFontWeight: String? |
| 161 | let fileHeaderFontSize: Double? |
| 162 | let fileHeaderFontWeight: String? |
| 163 | let fileHeaderMetaFontSize: Double? |
| 164 | let fileHeaderMetaFontWeight: String? |
| 165 | let fileHeaderSubtextFontSize: Double? |
| 166 | let fileHeaderSubtextFontWeight: String? |
| 167 | let fileHeaderStatusFontSize: Double? |
| 168 | let fileHeaderStatusFontWeight: String? |
| 169 | let emptyStateFontSize: Double? |
| 170 | let emptyStateFontWeight: String? |
| 171 | } |
| 172 | |
| 173 | private struct ReviewDiffNativeStyle { |
| 174 | let wordWrap: Bool |
| 175 | let rowHeight: CGFloat |
| 176 | let contentWidth: CGFloat |
| 177 | let changeBarWidth: CGFloat |
| 178 | let gutterWidth: CGFloat |
| 179 | let codePadding: CGFloat |
| 180 | let textVerticalInset: CGFloat |
| 181 | let fileHeaderHeight: CGFloat |
| 182 | let fileHeaderHorizontalMargin: CGFloat |
| 183 | let fileHeaderVerticalMargin: CGFloat |
| 184 | let fileHeaderCornerRadius: CGFloat |
| 185 | let fileHeaderHorizontalPadding: CGFloat |
| 186 | let fileHeaderPathRightPadding: CGFloat |
| 187 | let fileHeaderCountColumnWidth: CGFloat |
| 188 | let fileHeaderCountGap: CGFloat |
| 189 | let codeFontSize: CGFloat |
| 190 | let codeFontWeight: UIFont.Weight |
| 191 | let lineNumberFontSize: CGFloat |
| 192 | let lineNumberFontWeight: UIFont.Weight |
| 193 | let hunkFontSize: CGFloat |
| 194 | let hunkFontWeight: UIFont.Weight |
| 195 | let fileHeaderFontSize: CGFloat |
| 196 | let fileHeaderFontWeight: UIFont.Weight |
| 197 | let fileHeaderMetaFontSize: CGFloat |
| 198 | let fileHeaderMetaFontWeight: UIFont.Weight |
| 199 | let fileHeaderSubtextFontSize: CGFloat |
| 200 | let fileHeaderSubtextFontWeight: UIFont.Weight |
| 201 | let fileHeaderStatusFontSize: CGFloat |
| 202 | let fileHeaderStatusFontWeight: UIFont.Weight |
| 203 | let emptyStateFontSize: CGFloat |
| 204 | let emptyStateFontWeight: UIFont.Weight |
| 205 | |
| 206 | static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { |
| 207 | ReviewDiffNativeStyle( |
| 208 | wordWrap: payload?.wordWrap ?? false, |
| 209 | rowHeight: metric(payload?.rowHeight, fallback: 24), |
| 210 | contentWidth: metric(payload?.contentWidth, fallback: 2800), |
| 211 | changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), |
| 212 | gutterWidth: metric(payload?.gutterWidth, fallback: 50), |
| 213 | codePadding: metric(payload?.codePadding, fallback: 8), |
| 214 | textVerticalInset: metric(payload?.textVerticalInset, fallback: 3), |
| 215 | fileHeaderHeight: metric(payload?.fileHeaderHeight, fallback: 54), |
| 216 | fileHeaderHorizontalMargin: metric(payload?.fileHeaderHorizontalMargin, fallback: 8), |
| 217 | fileHeaderVerticalMargin: metric(payload?.fileHeaderVerticalMargin, fallback: 6), |
| 218 | fileHeaderCornerRadius: metric(payload?.fileHeaderCornerRadius, fallback: 10), |
| 219 | fileHeaderHorizontalPadding: metric(payload?.fileHeaderHorizontalPadding, fallback: 12), |
| 220 | fileHeaderPathRightPadding: metric(payload?.fileHeaderPathRightPadding, fallback: 128), |
| 221 | fileHeaderCountColumnWidth: metric(payload?.fileHeaderCountColumnWidth, fallback: 42), |
| 222 | fileHeaderCountGap: metric(payload?.fileHeaderCountGap, fallback: 6), |
| 223 | codeFontSize: metric(payload?.codeFontSize, fallback: 12), |
| 224 | codeFontWeight: fontWeight(payload?.codeFontWeight, fallback: .bold), |
| 225 | lineNumberFontSize: metric(payload?.lineNumberFontSize, fallback: 11), |
| 226 | lineNumberFontWeight: fontWeight(payload?.lineNumberFontWeight, fallback: .bold), |
| 227 | hunkFontSize: metric(payload?.hunkFontSize, fallback: 12), |
| 228 | hunkFontWeight: fontWeight(payload?.hunkFontWeight, fallback: .bold), |
| 229 | fileHeaderFontSize: metric(payload?.fileHeaderFontSize, fallback: 13), |
| 230 | fileHeaderFontWeight: fontWeight(payload?.fileHeaderFontWeight, fallback: .bold), |
| 231 | fileHeaderMetaFontSize: metric(payload?.fileHeaderMetaFontSize, fallback: 12), |
| 232 | fileHeaderMetaFontWeight: fontWeight(payload?.fileHeaderMetaFontWeight, fallback: .bold), |
| 233 | fileHeaderSubtextFontSize: metric(payload?.fileHeaderSubtextFontSize, fallback: 11), |
| 234 | fileHeaderSubtextFontWeight: fontWeight(payload?.fileHeaderSubtextFontWeight, fallback: .medium), |
| 235 | fileHeaderStatusFontSize: metric(payload?.fileHeaderStatusFontSize, fallback: 10), |
| 236 | fileHeaderStatusFontWeight: fontWeight(payload?.fileHeaderStatusFontWeight, fallback: .semibold), |
| 237 | emptyStateFontSize: metric(payload?.emptyStateFontSize, fallback: 13), |
| 238 | emptyStateFontWeight: fontWeight(payload?.emptyStateFontWeight, fallback: .semibold) |
| 239 | ) |
| 240 | } |
| 241 | |
| 242 | private static func metric(_ value: Double?, fallback: CGFloat) -> CGFloat { |
| 243 | guard let value, value.isFinite, value > 0 else { |
| 244 | return fallback |
| 245 | } |
| 246 | return CGFloat(value) |
| 247 | } |
| 248 | |
| 249 | private static func nonNegativeMetric(_ value: Double?, fallback: CGFloat) -> CGFloat { |
| 250 | guard let value, value.isFinite, value >= 0 else { |
| 251 | return fallback |
| 252 | } |
| 253 | return CGFloat(value) |
| 254 | } |
| 255 | |
| 256 | private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight { |
| 257 | switch value?.lowercased() { |
| 258 | case "ultralight", "ultra-light": |
| 259 | return .ultraLight |
| 260 | case "thin": |
| 261 | return .thin |
| 262 | case "light": |
| 263 | return .light |
| 264 | case "regular": |
| 265 | return .regular |
| 266 | case "medium": |
| 267 | return .medium |
| 268 | case "semibold", "semi-bold": |
| 269 | return .semibold |
| 270 | case "bold": |
| 271 | return .bold |
| 272 | case "heavy": |
| 273 | return .heavy |
| 274 | case "black": |
| 275 | return .black |
| 276 | default: |
| 277 | return fallback |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { |
| 282 | ReviewDiffNativeStyle( |
| 283 | wordWrap: wordWrap, |
| 284 | rowHeight: rowHeight ?? self.rowHeight, |
| 285 | contentWidth: contentWidth ?? self.contentWidth, |
| 286 | changeBarWidth: changeBarWidth, |
| 287 | gutterWidth: gutterWidth, |
| 288 | codePadding: codePadding, |
| 289 | textVerticalInset: textVerticalInset, |
| 290 | fileHeaderHeight: fileHeaderHeight, |
| 291 | fileHeaderHorizontalMargin: fileHeaderHorizontalMargin, |
| 292 | fileHeaderVerticalMargin: fileHeaderVerticalMargin, |
| 293 | fileHeaderCornerRadius: fileHeaderCornerRadius, |
| 294 | fileHeaderHorizontalPadding: fileHeaderHorizontalPadding, |
| 295 | fileHeaderPathRightPadding: fileHeaderPathRightPadding, |
| 296 | fileHeaderCountColumnWidth: fileHeaderCountColumnWidth, |
| 297 | fileHeaderCountGap: fileHeaderCountGap, |
| 298 | codeFontSize: codeFontSize, |
| 299 | codeFontWeight: codeFontWeight, |
| 300 | lineNumberFontSize: lineNumberFontSize, |
| 301 | lineNumberFontWeight: lineNumberFontWeight, |
| 302 | hunkFontSize: hunkFontSize, |
| 303 | hunkFontWeight: hunkFontWeight, |
| 304 | fileHeaderFontSize: fileHeaderFontSize, |
| 305 | fileHeaderFontWeight: fileHeaderFontWeight, |
| 306 | fileHeaderMetaFontSize: fileHeaderMetaFontSize, |
| 307 | fileHeaderMetaFontWeight: fileHeaderMetaFontWeight, |
| 308 | fileHeaderSubtextFontSize: fileHeaderSubtextFontSize, |
| 309 | fileHeaderSubtextFontWeight: fileHeaderSubtextFontWeight, |
| 310 | fileHeaderStatusFontSize: fileHeaderStatusFontSize, |
| 311 | fileHeaderStatusFontWeight: fileHeaderStatusFontWeight, |
| 312 | emptyStateFontSize: emptyStateFontSize, |
| 313 | emptyStateFontWeight: emptyStateFontWeight |
| 314 | ) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 319 | private let payloadDecodeQueue = DispatchQueue( |
| 320 | label: "com.t3tools.review-diff.payload-decode", |
| 321 | qos: .userInitiated |
| 322 | ) |
| 323 | private let scrollView = UIScrollView() |
| 324 | private let contentView = ReviewDiffContentView() |
| 325 | private var rows: [ReviewDiffNativeRow] = [] |
| 326 | private var appearanceScheme: String = "light" |
| 327 | private var themePayload: ReviewDiffNativeThemePayload? |
| 328 | private var stylePayload: ReviewDiffNativeStylePayload? |
| 329 | private var rowHeightOverride: CGFloat? |
| 330 | private var contentWidthOverride: CGFloat? |
| 331 | private var lastMetricsDebugKey = "" |
| 332 | private var lastVisibleRangeDebugKey = "" |
| 333 | private var tokensResetKey = "" |
| 334 | private var contentResetKey = "" |
| 335 | private var initialRowIndex: Int? |
| 336 | private var hasAppliedInitialRowIndex = false |
| 337 | private var lastVisibleFileId: String? |
| 338 | private var pendingScrollFileId: String? |
| 339 | private var pendingScrollAnimated = false |
| 340 | private var isProgrammaticScrollActive = false |
| 341 | private var rowsDecodeGeneration = 0 |
| 342 | private var tokensDecodeGeneration = 0 |
| 343 | |
| 344 | let onDebug = EventDispatcher() |
| 345 | let onVisibleFileChange = EventDispatcher() |
| 346 | let onToggleFile = EventDispatcher() |
| 347 | let onToggleViewedFile = EventDispatcher() |
| 348 | let onPressLine = EventDispatcher() |
| 349 | let onToggleComment = EventDispatcher() |
| 350 | let onPullToRefresh = EventDispatcher() |
| 351 | |
| 352 | private let pullRefreshControl = UIRefreshControl() |
| 353 | |
| 354 | public required init(appContext: AppContext? = nil) { |
| 355 | super.init(appContext: appContext) |
| 356 | |
| 357 | clipsToBounds = true |
| 358 | backgroundColor = contentView.theme.background |
| 359 | scrollView.contentInsetAdjustmentBehavior = .never |
| 360 | scrollView.delegate = self |
| 361 | scrollView.clipsToBounds = true |
| 362 | scrollView.alwaysBounceVertical = true |
| 363 | scrollView.alwaysBounceHorizontal = false |
| 364 | scrollView.showsVerticalScrollIndicator = true |
| 365 | scrollView.showsHorizontalScrollIndicator = false |
| 366 | scrollView.backgroundColor = contentView.theme.background |
| 367 | pullRefreshControl.addTarget(self, action: #selector(handlePullToRefresh), for: .valueChanged) |
| 368 | addSubview(scrollView) |
| 369 | |
| 370 | contentView.backgroundColor = contentView.theme.background |
| 371 | contentView.onToggleFile = { [weak self] fileId in |
| 372 | self?.onToggleFile(["fileId": fileId]) |
| 373 | } |
| 374 | contentView.onToggleViewedFile = { [weak self] fileId in |
| 375 | self?.onToggleViewedFile(["fileId": fileId]) |
| 376 | } |
| 377 | contentView.onPressLine = { [weak self] payload in |
| 378 | self?.onPressLine(payload) |
| 379 | } |
| 380 | contentView.onToggleComment = { [weak self] commentId in |
| 381 | self?.onToggleComment(["commentId": commentId]) |
| 382 | } |
| 383 | contentView.onDrawMetrics = { [weak self] metrics in |
| 384 | self?.emitDebug("draw-metrics", metrics) |
| 385 | } |
| 386 | scrollView.addSubview(contentView) |
| 387 | } |
| 388 | |
| 389 | public override func layoutSubviews() { |
| 390 | super.layoutSubviews() |
| 391 | scrollView.frame = bounds |
| 392 | updateContentMetrics() |
| 393 | } |
| 394 | |
| 395 | public func scrollViewDidScroll(_ scrollView: UIScrollView) { |
| 396 | if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating { |
| 397 | contentView.isVerticalScrollActive = true |
| 398 | } |
| 399 | updateViewportFrame() |
| 400 | } |
| 401 | |
| 402 | public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { |
| 403 | // A direct gesture takes ownership from any interrupted programmatic jump. |
| 404 | // Resume visible-file events immediately so the inspector follows the finger. |
| 405 | isProgrammaticScrollActive = false |
| 406 | contentView.isVerticalScrollActive = true |
| 407 | } |
| 408 | |
| 409 | public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { |
| 410 | guard !decelerate else { |
| 411 | return |
| 412 | } |
| 413 | |
| 414 | finishVerticalScroll() |
| 415 | } |
| 416 | |
| 417 | public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { |
| 418 | finishVerticalScroll() |
| 419 | } |
| 420 | |
| 421 | public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { |
| 422 | finishVerticalScroll() |
| 423 | } |
| 424 | |
| 425 | func setRowsJson(_ rowsJson: String) { |
| 426 | rowsDecodeGeneration += 1 |
| 427 | let generation = rowsDecodeGeneration |
| 428 | |
| 429 | payloadDecodeQueue.async { [weak self] in |
| 430 | guard let data = rowsJson.data(using: .utf8) else { |
| 431 | return |
| 432 | } |
| 433 | |
| 434 | do { |
| 435 | let decodedRows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data) |
| 436 | DispatchQueue.main.async { [weak self] in |
| 437 | guard let self, generation == self.rowsDecodeGeneration else { |
| 438 | return |
| 439 | } |
| 440 | self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in |
| 441 | generation == self?.rowsDecodeGeneration |
| 442 | }, completion: { [weak self] in |
| 443 | guard let self, generation == self.rowsDecodeGeneration else { return } |
| 444 | self.rows = decodedRows |
| 445 | self.contentView.rows = decodedRows |
| 446 | self.hasAppliedInitialRowIndex = false |
| 447 | self.lastVisibleFileId = nil |
| 448 | self.emitDebug("rows-decoded", [ |
| 449 | "rows": decodedRows.count, |
| 450 | "firstKind": decodedRows.first?.kind ?? "none", |
| 451 | ]) |
| 452 | self.updateContentMetrics() |
| 453 | self.applyPendingScrollIfNeeded() |
| 454 | }) |
| 455 | } |
| 456 | } catch { |
| 457 | let message = error.localizedDescription |
| 458 | DispatchQueue.main.async { [weak self] in |
| 459 | guard let self, generation == self.rowsDecodeGeneration else { |
| 460 | return |
| 461 | } |
| 462 | self.rows = [] |
| 463 | self.contentView.rows = [] |
| 464 | self.hasAppliedInitialRowIndex = false |
| 465 | self.lastVisibleFileId = nil |
| 466 | self.pendingScrollFileId = nil |
| 467 | self.updateContentMetrics() |
| 468 | self.emitDebug("rows-decode-failed", ["error": message]) |
| 469 | } |
| 470 | } |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | func setTokensJson(_ tokensJson: String) { |
| 475 | tokensDecodeGeneration += 1 |
| 476 | let generation = tokensDecodeGeneration |
| 477 | |
| 478 | payloadDecodeQueue.async { [weak self] in |
| 479 | guard let data = tokensJson.data(using: .utf8) else { |
| 480 | return |
| 481 | } |
| 482 | |
| 483 | do { |
| 484 | let decodedTokens = try JSONDecoder().decode( |
| 485 | [String: [ReviewDiffNativeToken]].self, |
| 486 | from: data |
| 487 | ) |
| 488 | DispatchQueue.main.async { [weak self] in |
| 489 | guard let self, generation == self.tokensDecodeGeneration else { |
| 490 | return |
| 491 | } |
| 492 | self.contentView.tokensByRowId = decodedTokens |
| 493 | } |
| 494 | } catch { |
| 495 | let message = error.localizedDescription |
| 496 | DispatchQueue.main.async { [weak self] in |
| 497 | guard let self, generation == self.tokensDecodeGeneration else { |
| 498 | return |
| 499 | } |
| 500 | self.contentView.tokensByRowId = [:] |
| 501 | self.emitDebug("tokens-decode-failed", ["error": message]) |
| 502 | } |
| 503 | } |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | func setTokensPatchJson(_ tokensPatchJson: String) { |
| 508 | let contentResetKey = self.contentResetKey |
| 509 | |
| 510 | payloadDecodeQueue.async { [weak self] in |
| 511 | guard let data = tokensPatchJson.data(using: .utf8) else { |
| 512 | return |
| 513 | } |
| 514 | |
| 515 | do { |
| 516 | let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data) |
| 517 | DispatchQueue.main.async { [weak self] in |
| 518 | guard let self else { |
| 519 | return |
| 520 | } |
| 521 | guard contentResetKey == self.contentResetKey else { |
| 522 | return |
| 523 | } |
| 524 | // A highlighter request from the previous file can finish after the view has |
| 525 | // already reset. Never let that stale patch roll the native token state back. |
| 526 | if let resetKey = patch.resetKey, resetKey != self.tokensResetKey { |
| 527 | return |
| 528 | } |
| 529 | |
| 530 | let tokensByRowId = patch.tokensByRowId ?? [:] |
| 531 | if tokensByRowId.isEmpty { |
| 532 | return |
| 533 | } |
| 534 | |
| 535 | self.contentView.mergeTokensByRowId(tokensByRowId) |
| 536 | if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) { |
| 537 | self.emitDebug("tokens-patch-decoded", [ |
| 538 | "chunkIndex": chunkIndex, |
| 539 | "rows": tokensByRowId.count, |
| 540 | "totalRows": self.contentView.tokensByRowId.count, |
| 541 | ]) |
| 542 | } |
| 543 | } |
| 544 | } catch { |
| 545 | let message = error.localizedDescription |
| 546 | DispatchQueue.main.async { [weak self] in |
| 547 | self?.emitDebug("tokens-patch-decode-failed", ["error": message]) |
| 548 | } |
| 549 | } |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | func setTokensResetKey(_ tokensResetKey: String) { |
| 554 | guard tokensResetKey != self.tokensResetKey else { |
| 555 | return |
| 556 | } |
| 557 | |
| 558 | self.tokensResetKey = tokensResetKey |
| 559 | contentView.tokensByRowId = [:] |
| 560 | emitDebug("tokens-reset", [ |
| 561 | "resetKey": tokensResetKey, |
| 562 | ]) |
| 563 | } |
| 564 | |
| 565 | // Code surfaces fill the screen so UIKit can sample content below the glass bar. |
| 566 | // Keep the same visible row when header or safe-area reservations change. |
| 567 | func setContentInsetTop(_ inset: CGFloat) { |
| 568 | scrollView.automaticallyAdjustsScrollIndicatorInsets = false |
| 569 | let inset = max(0, inset) |
| 570 | let delta = inset - scrollView.contentInset.top |
| 571 | guard delta != 0 else { return } |
| 572 | scrollView.contentInset.top = inset |
| 573 | contentView.topContentInset = inset |
| 574 | scrollView.verticalScrollIndicatorInsets.top = inset |
| 575 | scrollView.contentOffset.y -= delta |
| 576 | updateViewportFrame() |
| 577 | } |
| 578 | |
| 579 | func setContentInsetBottom(_ inset: CGFloat) { |
| 580 | scrollView.contentInset.bottom = max(0, inset) |
| 581 | scrollView.verticalScrollIndicatorInsets.bottom = max(0, inset) |
| 582 | } |
| 583 | |
| 584 | func setContentResetKey(_ contentResetKey: String) { |
| 585 | guard contentResetKey != self.contentResetKey else { |
| 586 | return |
| 587 | } |
| 588 | |
| 589 | self.contentResetKey = contentResetKey |
| 590 | rowsDecodeGeneration += 1 |
| 591 | tokensDecodeGeneration += 1 |
| 592 | contentView.tokensByRowId = [:] |
| 593 | rows = [] |
| 594 | contentView.rows = [] |
| 595 | hasAppliedInitialRowIndex = false |
| 596 | pendingScrollFileId = nil |
| 597 | isProgrammaticScrollActive = false |
| 598 | scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: false) |
| 599 | updateContentMetrics() |
| 600 | updateViewportFrame() |
| 601 | lastVisibleFileId = nil |
| 602 | applyInitialRowIndexIfNeeded() |
| 603 | } |
| 604 | |
| 605 | func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) { |
| 606 | let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson) |
| 607 | let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds) |
| 608 | let scrollAnchor: ReviewDiffScrollAnchor? |
| 609 | if changedFileIds.count == 1, let changedFileId = changedFileIds.first { |
| 610 | scrollAnchor = contentView.scrollAnchor(forFileId: changedFileId) |
| 611 | } else { |
| 612 | scrollAnchor = nil |
| 613 | } |
| 614 | |
| 615 | contentView.collapsedFileIds = nextCollapsedFileIds |
| 616 | updateContentMetrics() |
| 617 | |
| 618 | if let scrollAnchor, |
| 619 | let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) { |
| 620 | let targetOffset = headerOffset - scrollAnchor.screenY |
| 621 | let minOffset = -scrollView.contentInset.top |
| 622 | let maxOffset = max( |
| 623 | scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, |
| 624 | minOffset |
| 625 | ) |
| 626 | let clampedOffset = min(max(targetOffset, minOffset), maxOffset) |
| 627 | scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false) |
| 628 | updateViewportFrame() |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | func setViewedFileIdsJson(_ viewedFileIdsJson: String) { |
| 633 | contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson) |
| 634 | } |
| 635 | |
| 636 | func setSelectedRowIdsJson(_ selectedRowIdsJson: String) { |
| 637 | contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson) |
| 638 | } |
| 639 | |
| 640 | func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) { |
| 641 | contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson) |
| 642 | updateContentMetrics() |
| 643 | } |
| 644 | |
| 645 | private func decodeFileIdSet(_ json: String) -> Set<String> { |
| 646 | guard let data = json.data(using: .utf8) else { |
| 647 | return [] |
| 648 | } |
| 649 | |
| 650 | do { |
| 651 | return Set(try JSONDecoder().decode([String].self, from: data)) |
| 652 | } catch { |
| 653 | emitDebug("file-id-set-decode-failed", [ |
| 654 | "error": error.localizedDescription, |
| 655 | ]) |
| 656 | return [] |
| 657 | } |
| 658 | } |
| 659 | |
| 660 | func setAppearanceScheme(_ appearanceScheme: String) { |
| 661 | self.appearanceScheme = appearanceScheme |
| 662 | applyTheme() |
| 663 | } |
| 664 | |
| 665 | func setThemeJson(_ themeJson: String) { |
| 666 | guard let data = themeJson.data(using: .utf8) else { |
| 667 | themePayload = nil |
| 668 | applyTheme() |
| 669 | return |
| 670 | } |
| 671 | |
| 672 | do { |
| 673 | themePayload = try JSONDecoder().decode(ReviewDiffNativeThemePayload.self, from: data) |
| 674 | } catch { |
| 675 | themePayload = nil |
| 676 | emitDebug("theme-decode-failed", [ |
| 677 | "error": error.localizedDescription, |
| 678 | ]) |
| 679 | } |
| 680 | |
| 681 | applyTheme() |
| 682 | } |
| 683 | |
| 684 | private func updateContentMetrics() { |
| 685 | let style = contentView.style |
| 686 | contentView.viewportWidth = bounds.width |
| 687 | let height = max(bounds.height, contentView.contentHeight) |
| 688 | let width = bounds.width |
| 689 | scrollView.contentSize = CGSize(width: bounds.width, height: height) |
| 690 | contentView.frame = CGRect( |
| 691 | x: 0, |
| 692 | y: scrollView.contentOffset.y, |
| 693 | width: max(width, 1), |
| 694 | height: max(bounds.height, 1) |
| 695 | ) |
| 696 | contentView.verticalOffset = scrollView.contentOffset.y |
| 697 | contentView.invalidateVisibleViewport() |
| 698 | contentView.setNeedsDisplay() |
| 699 | applyInitialRowIndexIfNeeded() |
| 700 | applyPendingScrollIfNeeded() |
| 701 | emitVisibleFileIfNeeded() |
| 702 | |
| 703 | let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(contentView.contentHeight))" |
| 704 | if debugKey != lastMetricsDebugKey { |
| 705 | lastMetricsDebugKey = debugKey |
| 706 | emitDebug("metrics", [ |
| 707 | "rows": rows.count, |
| 708 | "boundsWidth": Double(bounds.width), |
| 709 | "boundsHeight": Double(bounds.height), |
| 710 | "contentHeight": Double(contentView.contentHeight), |
| 711 | "contentWidth": Double(style.contentWidth), |
| 712 | "fileHeaderHeight": Double(style.fileHeaderHeight), |
| 713 | "rowHeight": Double(style.rowHeight), |
| 714 | ]) |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | private func emitDebug(_ message: String, _ details: [String: Any]) { |
| 719 | var payload = details |
| 720 | payload["message"] = message |
| 721 | onDebug(payload) |
| 722 | } |
| 723 | |
| 724 | private func finishVerticalScroll() { |
| 725 | isProgrammaticScrollActive = false |
| 726 | contentView.isVerticalScrollActive = false |
| 727 | updateViewportFrame() |
| 728 | emitVisibleRange(reason: "scroll-end") |
| 729 | } |
| 730 | |
| 731 | private func emitVisibleFileIfNeeded() { |
| 732 | // Keep the explicit destination selected while UIKit animates through the files |
| 733 | // between the old and new offsets. Emitting every intermediate header forces a |
| 734 | // React render per crossing and makes the navigator visibly flash. |
| 735 | guard !isProgrammaticScrollActive else { |
| 736 | return |
| 737 | } |
| 738 | |
| 739 | // The top of the combined diff is the explicit "All files" destination. |
| 740 | // Treat it as a first-class selection instead of immediately resolving the |
| 741 | // first file header and undoing the navigator's optimistic selection. |
| 742 | let visibleTop = scrollView.contentOffset.y + scrollView.contentInset.top |
| 743 | if visibleTop <= 0.5 { |
| 744 | guard lastVisibleFileId != nil else { |
| 745 | return |
| 746 | } |
| 747 | lastVisibleFileId = nil |
| 748 | onVisibleFileChange(["fileId": NSNull()]) |
| 749 | return |
| 750 | } |
| 751 | |
| 752 | guard let fileId = contentView.visibleFileId(atVerticalOffset: visibleTop), |
| 753 | fileId != lastVisibleFileId else { |
| 754 | return |
| 755 | } |
| 756 | |
| 757 | lastVisibleFileId = fileId |
| 758 | onVisibleFileChange(["fileId": fileId]) |
| 759 | } |
| 760 | |
| 761 | private func emitVisibleRange(reason: String) { |
| 762 | guard let range = contentView.currentVisibleRowRange() else { |
| 763 | return |
| 764 | } |
| 765 | |
| 766 | let debugKey = "\(range.firstRowIndex):\(range.lastRowIndex):\(Int(scrollView.bounds.height))" |
| 767 | guard debugKey != lastVisibleRangeDebugKey else { |
| 768 | return |
| 769 | } |
| 770 | |
| 771 | lastVisibleRangeDebugKey = debugKey |
| 772 | emitDebug("visible-range", [ |
| 773 | "reason": reason, |
| 774 | "firstRowIndex": range.firstRowIndex, |
| 775 | "lastRowIndex": range.lastRowIndex, |
| 776 | "totalRows": rows.count, |
| 777 | ]) |
| 778 | } |
| 779 | |
| 780 | private func applyTheme() { |
| 781 | contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, payload: themePayload) |
| 782 | backgroundColor = contentView.theme.background |
| 783 | scrollView.backgroundColor = contentView.theme.background |
| 784 | contentView.backgroundColor = contentView.theme.background |
| 785 | contentView.invalidateVisibleViewport() |
| 786 | } |
| 787 | |
| 788 | func setStyleJson(_ styleJson: String) { |
| 789 | guard let data = styleJson.data(using: .utf8) else { |
| 790 | stylePayload = nil |
| 791 | applyStyle() |
| 792 | return |
| 793 | } |
| 794 | |
| 795 | do { |
| 796 | stylePayload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: data) |
| 797 | } catch { |
| 798 | stylePayload = nil |
| 799 | emitDebug("style-decode-failed", [ |
| 800 | "error": error.localizedDescription, |
| 801 | ]) |
| 802 | } |
| 803 | |
| 804 | applyStyle() |
| 805 | } |
| 806 | |
| 807 | func setRowHeight(_ rowHeight: CGFloat) { |
| 808 | rowHeightOverride = rowHeight.isFinite && rowHeight > 0 ? rowHeight : nil |
| 809 | applyStyle() |
| 810 | } |
| 811 | |
| 812 | func setContentWidth(_ contentWidth: CGFloat) { |
| 813 | contentWidthOverride = contentWidth.isFinite && contentWidth > 0 ? contentWidth : nil |
| 814 | applyStyle() |
| 815 | } |
| 816 | |
| 817 | func setInitialRowIndex(_ initialRowIndex: Double) { |
| 818 | let nextIndex: Int? = initialRowIndex.isFinite && initialRowIndex >= 0 |
| 819 | ? Int(initialRowIndex.rounded(.down)) |
| 820 | : nil |
| 821 | guard nextIndex != self.initialRowIndex else { |
| 822 | return |
| 823 | } |
| 824 | |
| 825 | self.initialRowIndex = nextIndex |
| 826 | hasAppliedInitialRowIndex = false |
| 827 | applyInitialRowIndexIfNeeded() |
| 828 | } |
| 829 | |
| 830 | func scrollToFile(_ fileId: String, animated: Bool) { |
| 831 | pendingScrollFileId = fileId |
| 832 | pendingScrollAnimated = animated |
| 833 | applyPendingScrollIfNeeded() |
| 834 | } |
| 835 | |
| 836 | func scrollToTop(animated: Bool) { |
| 837 | pendingScrollFileId = nil |
| 838 | pendingScrollAnimated = false |
| 839 | setVerticalContentOffset(0, animated: animated) |
| 840 | } |
| 841 | |
| 842 | func setScrollEnabled(_ enabled: Bool) { |
| 843 | scrollView.isScrollEnabled = enabled |
| 844 | } |
| 845 | |
| 846 | func setBounces(_ bounces: Bool) { |
| 847 | scrollView.bounces = bounces |
| 848 | scrollView.alwaysBounceVertical = bounces |
| 849 | } |
| 850 | |
| 851 | func setRefreshEnabled(_ enabled: Bool) { |
| 852 | if !enabled { pullRefreshControl.endRefreshing() } |
| 853 | scrollView.refreshControl = enabled ? pullRefreshControl : nil |
| 854 | } |
| 855 | |
| 856 | @objc private func handlePullToRefresh() { |
| 857 | onPullToRefresh([:]) |
| 858 | } |
| 859 | |
| 860 | /// Driven from JS: set to false once the reload completes to dismiss the spinner. |
| 861 | func setRefreshing(_ refreshing: Bool) { |
| 862 | if refreshing { |
| 863 | if !pullRefreshControl.isRefreshing { |
| 864 | pullRefreshControl.beginRefreshing() |
| 865 | } |
| 866 | } else if pullRefreshControl.isRefreshing { |
| 867 | pullRefreshControl.endRefreshing() |
| 868 | } |
| 869 | } |
| 870 | |
| 871 | private func applyStyle() { |
| 872 | contentView.style = ReviewDiffNativeStyle |
| 873 | .resolve(stylePayload) |
| 874 | .applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride) |
| 875 | updateContentMetrics() |
| 876 | } |
| 877 | |
| 878 | private func updateViewportFrame() { |
| 879 | contentView.frame = CGRect( |
| 880 | x: 0, |
| 881 | y: scrollView.contentOffset.y, |
| 882 | width: max(bounds.width, 1), |
| 883 | height: max(bounds.height, 1) |
| 884 | ) |
| 885 | contentView.verticalOffset = scrollView.contentOffset.y |
| 886 | contentView.invalidateVisibleViewport() |
| 887 | emitVisibleFileIfNeeded() |
| 888 | } |
| 889 | |
| 890 | private func applyPendingScrollIfNeeded() { |
| 891 | guard let fileId = pendingScrollFileId, |
| 892 | bounds.height > 0 else { |
| 893 | return |
| 894 | } |
| 895 | |
| 896 | guard let headerOffset = contentView.fileHeaderOffset(forFileId: fileId) else { |
| 897 | if !rows.isEmpty { |
| 898 | pendingScrollFileId = nil |
| 899 | } |
| 900 | return |
| 901 | } |
| 902 | |
| 903 | let animated = pendingScrollAnimated |
| 904 | pendingScrollFileId = nil |
| 905 | pendingScrollAnimated = false |
| 906 | setVerticalContentOffset(headerOffset, animated: animated) |
| 907 | } |
| 908 | |
| 909 | private func setVerticalContentOffset(_ targetOffset: CGFloat, animated: Bool) { |
| 910 | let topInset = scrollView.contentInset.top |
| 911 | let maxOffset = max( |
| 912 | scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, |
| 913 | -topInset |
| 914 | ) |
| 915 | let clampedOffset = min(max(targetOffset - topInset, -topInset), maxOffset) |
| 916 | let shouldAnimate = animated && abs(scrollView.contentOffset.y - clampedOffset) > 0.5 |
| 917 | isProgrammaticScrollActive = shouldAnimate |
| 918 | contentView.isVerticalScrollActive = shouldAnimate |
| 919 | scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: shouldAnimate) |
| 920 | if !shouldAnimate { |
| 921 | updateViewportFrame() |
| 922 | } |
| 923 | } |
| 924 | |
| 925 | private func applyInitialRowIndexIfNeeded() { |
| 926 | guard !hasAppliedInitialRowIndex, |
| 927 | let initialRowIndex, |
| 928 | bounds.height > 0, |
| 929 | let rowFrame = contentView.frameForRow(at: initialRowIndex) else { |
| 930 | return |
| 931 | } |
| 932 | |
| 933 | let visibleHeight = bounds.height - scrollView.contentInset.top - scrollView.contentInset.bottom |
| 934 | let targetScreenY = max(0, (visibleHeight - rowFrame.height) * 0.3) |
| 935 | hasAppliedInitialRowIndex = true |
| 936 | setVerticalContentOffset(rowFrame.minY - targetScreenY, animated: false) |
| 937 | } |
| 938 | } |
| 939 | |
| 940 | private enum ReviewDiffHorizontalPanKind { |
| 941 | case code |
| 942 | case fileHeaderPath |
| 943 | } |
| 944 | |
| 945 | private struct ReviewDiffFileHeaderPathLayout { |
| 946 | let displayPath: String |
| 947 | let rect: CGRect |
| 948 | } |
| 949 | |
| 950 | private struct ReviewDiffFileHeaderInteractiveRects { |
| 951 | let chevron: CGRect |
| 952 | let icon: CGRect |
| 953 | let checkbox: CGRect |
| 954 | } |
| 955 | |
| 956 | private struct ReviewDiffStickyFileHeaderIndex { |
| 957 | let position: Int |
| 958 | let rowIndex: Int |
| 959 | } |
| 960 | |
| 961 | private struct ReviewDiffStickyFileHeaderTarget { |
| 962 | let rowIndex: Int |
| 963 | let row: ReviewDiffNativeRow |
| 964 | let rect: CGRect |
| 965 | } |
| 966 | |
| 967 | private struct ReviewDiffScrollAnchor { |
| 968 | let fileId: String |
| 969 | let screenY: CGFloat |
| 970 | } |
| 971 | |
| 972 | private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 973 | var rows: [ReviewDiffNativeRow] = [] { |
| 974 | didSet { |
| 975 | stopHorizontalDeceleration() |
| 976 | horizontalOffsetsByFileId.removeAll() |
| 977 | headerPathOffsetsByFileId.removeAll() |
| 978 | activePanFileId = nil |
| 979 | activePanKind = nil |
| 980 | codeDecorationVersion += 1 |
| 981 | tokenAttributedStringsByRowId.removeAll() |
| 982 | rebuildRowLayout() |
| 983 | setNeedsDisplayForVisibleBounds() |
| 984 | } |
| 985 | } |
| 986 | var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { |
| 987 | didSet { |
| 988 | codeDecorationVersion += 1 |
| 989 | tokenAttributedStringsByRowId.removeAll() |
| 990 | clampHorizontalOffsets() |
| 991 | setNeedsDisplayForVisibleBounds() |
| 992 | } |
| 993 | } |
| 994 | |
| 995 | func mergeTokensByRowId(_ tokensPatch: [String: [ReviewDiffNativeToken]]) { |
| 996 | tokensPatch.forEach { rowId, tokens in |
| 997 | tokensByRowId[rowId] = tokens |
| 998 | tokenAttributedStringsByRowId.removeValue(forKey: rowId) |
| 999 | } |
| 1000 | clampHorizontalOffsets() |
| 1001 | setNeedsDisplayForVisibleBounds() |
| 1002 | } |
| 1003 | |
| 1004 | var collapsedFileIds: Set<String> = [] { |
| 1005 | didSet { |
| 1006 | rebuildRowLayout() |
| 1007 | clampHorizontalOffsets() |
| 1008 | setNeedsDisplayForVisibleBounds() |
| 1009 | } |
| 1010 | } |
| 1011 | var viewedFileIds: Set<String> = [] { |
| 1012 | didSet { |
| 1013 | setNeedsDisplayForVisibleBounds() |
| 1014 | } |
| 1015 | } |
| 1016 | var selectedRowIds: Set<String> = [] { |
| 1017 | didSet { |
| 1018 | setNeedsDisplayForVisibleBounds() |
| 1019 | } |
| 1020 | } |
| 1021 | var collapsedCommentIds: Set<String> = [] { |
| 1022 | didSet { |
| 1023 | rebuildRowLayout() |
| 1024 | setNeedsDisplayForVisibleBounds() |
| 1025 | } |
| 1026 | } |
| 1027 | var style = ReviewDiffNativeStyle.resolve(nil) { |
| 1028 | didSet { |
| 1029 | codeDecorationVersion += 1 |
| 1030 | tokenAttributedStringsByRowId.removeAll() |
| 1031 | rebuildRowLayout() |
| 1032 | clampHorizontalOffsets() |
| 1033 | setNeedsDisplayForVisibleBounds() |
| 1034 | } |
| 1035 | } |
| 1036 | var viewportWidth: CGFloat = 0 { |
| 1037 | didSet { |
| 1038 | // Wrapped rows take their height from the width, so a new width is a new layout. |
| 1039 | if style.wordWrap, viewportWidth != oldValue { |
| 1040 | rebuildRowLayout() |
| 1041 | } |
| 1042 | clampHorizontalOffsets() |
| 1043 | setNeedsDisplayForVisibleBounds() |
| 1044 | } |
| 1045 | } |
| 1046 | var verticalOffset: CGFloat = 0 |
| 1047 | var topContentInset: CGFloat = 0 |
| 1048 | var theme = ReviewDiffNativeTheme.resolve("light") { |
| 1049 | didSet { |
| 1050 | tokenColorsByHex.removeAll() |
| 1051 | codeDecorationVersion += 1 |
| 1052 | tokenAttributedStringsByRowId.removeAll() |
| 1053 | setNeedsDisplayForVisibleBounds() |
| 1054 | } |
| 1055 | } |
| 1056 | private(set) var contentHeight: CGFloat = 0 |
| 1057 | |
| 1058 | private var rowOffsets: [CGFloat] = [] |
| 1059 | private var fileHeaderRowIndices: [Int] = [] |
| 1060 | private var contentWidthsByFileId: [String: CGFloat] = [:] |
| 1061 | private var tokenColorsByHex: [String: UIColor] = [:] |
| 1062 | private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] |
| 1063 | private var codeCharacterWidth: CGFloat = 8 |
| 1064 | /// Columns per visual line while word wrap is on; nil while code rows pan horizontally. |
| 1065 | private var codeWrapColumns: Int? |
| 1066 | /// Text geometry survives comment height changes; width, font, and content invalidate it. |
| 1067 | private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] |
| 1068 | private var codeLayoutWidth: CGFloat = 0 |
| 1069 | private var codeLayoutFont: UIFont? |
| 1070 | private var codeDecorationVersion = 0 |
| 1071 | private var panStartHorizontalOffset: CGFloat = 0 |
| 1072 | private var activePanFileId: String? |
| 1073 | private var activePanKind: ReviewDiffHorizontalPanKind? |
| 1074 | private var horizontalOffsetsByFileId: [String: CGFloat] = [:] |
| 1075 | private var headerPathOffsetsByFileId: [String: CGFloat] = [:] |
| 1076 | private var decelerationDisplayLink: CADisplayLink? |
| 1077 | private var deceleratingFileId: String? |
| 1078 | private var horizontalVelocity: CGFloat = 0 |
| 1079 | private var lastDecelerationTimestamp: CFTimeInterval = 0 |
| 1080 | private var lastDrawMetricsTimestamp: CFTimeInterval = 0 |
| 1081 | var isVerticalScrollActive = false |
| 1082 | var onToggleFile: ((String) -> Void)? |
| 1083 | var onToggleViewedFile: ((String) -> Void)? |
| 1084 | var onPressLine: (([String: Any]) -> Void)? |
| 1085 | var onToggleComment: ((String) -> Void)? |
| 1086 | var onDrawMetrics: (([String: Any]) -> Void)? |
| 1087 | |
| 1088 | private var stickyWidth: CGFloat { |
| 1089 | style.changeBarWidth + style.gutterWidth |
| 1090 | } |
| 1091 | |
| 1092 | private var codeStartX: CGFloat { |
| 1093 | stickyWidth + style.codePadding |
| 1094 | } |
| 1095 | |
| 1096 | /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. |
| 1097 | private func height(for row: ReviewDiffNativeRow) -> CGFloat { |
| 1098 | if row.kind == "file" { |
| 1099 | return style.fileHeaderHeight |
| 1100 | } |
| 1101 | if collapsedFileIds.contains(resolvedFileId(for: row)) { |
| 1102 | return 0 |
| 1103 | } |
| 1104 | if row.kind == "notice" { |
| 1105 | return max(style.rowHeight * 2, 44) |
| 1106 | } |
| 1107 | if row.kind == "comment" { |
| 1108 | return collapsedCommentIds.contains(row.id) ? 44 : 124 |
| 1109 | } |
| 1110 | return style.rowHeight |
| 1111 | } |
| 1112 | |
| 1113 | /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. |
| 1114 | private func height(at index: Int) -> CGFloat { |
| 1115 | let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight |
| 1116 | return nextOffset - rowOffsets[index] |
| 1117 | } |
| 1118 | |
| 1119 | private var codeWrapLineHeight: CGFloat { |
| 1120 | ceil(codeFont.lineHeight) |
| 1121 | } |
| 1122 | |
| 1123 | func frameForRow(at index: Int) -> CGRect? { |
| 1124 | guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { |
| 1125 | return nil |
| 1126 | } |
| 1127 | |
| 1128 | return CGRect( |
| 1129 | x: 0, |
| 1130 | y: rowOffsets[index], |
| 1131 | width: max(viewportWidth, 1), |
| 1132 | height: height(at: index) |
| 1133 | ) |
| 1134 | } |
| 1135 | |
| 1136 | /// Shape new content on the existing decode worker before publishing rows to the UI. |
| 1137 | func prepareRows( |
| 1138 | _ rows: [ReviewDiffNativeRow], |
| 1139 | on queue: DispatchQueue, |
| 1140 | isCurrent: @escaping () -> Bool, |
| 1141 | completion: @escaping () -> Void |
| 1142 | ) { |
| 1143 | let font = codeFont |
| 1144 | let width = viewportWidth - codeStartX - style.codePadding |
| 1145 | let characterWidth = monospaceCharacterWidth(font: font) |
| 1146 | guard style.wordWrap, width >= characterWidth, characterWidth > 0 else { |
| 1147 | completion() |
| 1148 | return |
| 1149 | } |
| 1150 | let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] |
| 1151 | queue.async { [weak self] in |
| 1152 | var layouts: [String: ReviewDiffCodeLayout] = [:] |
| 1153 | for row in rows where row.kind == "line" { |
| 1154 | guard let text = row.content else { continue } |
| 1155 | if let previous = cached[row.id], previous.text == text { |
| 1156 | layouts[row.id] = previous |
| 1157 | } else { |
| 1158 | layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) |
| 1159 | } |
| 1160 | } |
| 1161 | DispatchQueue.main.async { [weak self] in |
| 1162 | guard let self, isCurrent() else { return } |
| 1163 | if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width { |
| 1164 | self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) |
| 1165 | return |
| 1166 | } |
| 1167 | self.codeLayoutWidth = width |
| 1168 | self.codeLayoutFont = font |
| 1169 | self.codeLayoutsByRowId = layouts |
| 1170 | completion() |
| 1171 | } |
| 1172 | } |
| 1173 | } |
| 1174 | |
| 1175 | private func rebuildRowLayout() { |
| 1176 | var nextOffsets: [CGFloat] = [] |
| 1177 | var nextFileHeaderRowIndices: [Int] = [] |
| 1178 | nextOffsets.reserveCapacity(rows.count) |
| 1179 | var maxColumnCountsByFileId: [String: Int] = [:] |
| 1180 | var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] |
| 1181 | var offset: CGFloat = 0 |
| 1182 | let font = codeFont |
| 1183 | let characterWidth = monospaceCharacterWidth(font: font) |
| 1184 | let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding |
| 1185 | let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth |
| 1186 | ? Int(wrapAvailableWidth / characterWidth) |
| 1187 | : nil |
| 1188 | if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font { |
| 1189 | codeLayoutsByRowId.removeAll() |
| 1190 | codeLayoutWidth = wrapAvailableWidth |
| 1191 | codeLayoutFont = font |
| 1192 | } |
| 1193 | |
| 1194 | for (index, row) in rows.enumerated() { |
| 1195 | nextOffsets.append(offset) |
| 1196 | if row.kind == "file" { |
| 1197 | nextFileHeaderRowIndices.append(index) |
| 1198 | } |
| 1199 | var rowHeight = height(for: row) |
| 1200 | |
| 1201 | let fileId = resolvedFileId(for: row) |
| 1202 | switch row.kind { |
| 1203 | case "line": |
| 1204 | // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. |
| 1205 | let columnCount = row.content?.utf16.count ?? 0 |
| 1206 | maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount) |
| 1207 | if wrapColumns != nil, let content = row.content { |
| 1208 | let cached = codeLayoutsByRowId[row.id] |
| 1209 | let layout: ReviewDiffCodeLayout |
| 1210 | if let cached, cached.text == content { |
| 1211 | layout = cached |
| 1212 | } else { |
| 1213 | layout = ReviewDiffCodeLayout( |
| 1214 | text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth |
| 1215 | ) |
| 1216 | } |
| 1217 | nextCodeLayouts[row.id] = layout |
| 1218 | if rowHeight > 0 { |
| 1219 | rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight |
| 1220 | } |
| 1221 | } |
| 1222 | case "hunk": |
| 1223 | maxColumnCountsByFileId[fileId] = max( |
| 1224 | maxColumnCountsByFileId[fileId] ?? 0, |
| 1225 | row.text?.utf16.count ?? 0 |
| 1226 | ) |
| 1227 | default: |
| 1228 | break |
| 1229 | } |
| 1230 | offset += rowHeight |
| 1231 | } |
| 1232 | |
| 1233 | codeCharacterWidth = characterWidth |
| 1234 | codeWrapColumns = wrapColumns |
| 1235 | codeLayoutsByRowId = nextCodeLayouts |
| 1236 | contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in |
| 1237 | let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 |
| 1238 | return max(0, min(style.contentWidth, measuredWidth)) |
| 1239 | } |
| 1240 | rowOffsets = nextOffsets |
| 1241 | fileHeaderRowIndices = nextFileHeaderRowIndices |
| 1242 | contentHeight = offset |
| 1243 | } |
| 1244 | |
| 1245 | private var codeFont: UIFont { |
| 1246 | UIFont.monospacedSystemFont(ofSize: style.codeFontSize, weight: style.codeFontWeight) |
| 1247 | } |
| 1248 | |
| 1249 | private var lineNumberFont: UIFont { |
| 1250 | UIFont.monospacedSystemFont(ofSize: style.lineNumberFontSize, weight: style.lineNumberFontWeight) |
| 1251 | } |
| 1252 | |
| 1253 | private var hunkFont: UIFont { |
| 1254 | UIFont.monospacedSystemFont(ofSize: style.hunkFontSize, weight: style.hunkFontWeight) |
| 1255 | } |
| 1256 | |
| 1257 | private var fileHeaderFont: UIFont { |
| 1258 | UIFont.systemFont(ofSize: style.fileHeaderFontSize, weight: style.fileHeaderFontWeight) |
| 1259 | } |
| 1260 | |
| 1261 | private var fileHeaderMetaFont: UIFont { |
| 1262 | UIFont.systemFont(ofSize: style.fileHeaderMetaFontSize, weight: style.fileHeaderMetaFontWeight) |
| 1263 | } |
| 1264 | |
| 1265 | private var fileHeaderSubtextFont: UIFont { |
| 1266 | UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight) |
| 1267 | } |
| 1268 | |
| 1269 | private var fileHeaderStatusFont: UIFont { |
| 1270 | UIFont.systemFont(ofSize: style.fileHeaderStatusFontSize, weight: style.fileHeaderStatusFontWeight) |
| 1271 | } |
| 1272 | |
| 1273 | private var emptyStateFont: UIFont { |
| 1274 | UIFont.systemFont(ofSize: style.emptyStateFontSize, weight: style.emptyStateFontWeight) |
| 1275 | } |
| 1276 | |
| 1277 | private lazy var horizontalPanGesture: UIPanGestureRecognizer = { |
| 1278 | let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleHorizontalPan(_:))) |
| 1279 | gesture.delegate = self |
| 1280 | gesture.cancelsTouchesInView = false |
| 1281 | return gesture |
| 1282 | }() |
| 1283 | |
| 1284 | private lazy var tapGesture: UITapGestureRecognizer = { |
| 1285 | let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) |
| 1286 | gesture.delegate = self |
| 1287 | gesture.cancelsTouchesInView = false |
| 1288 | return gesture |
| 1289 | }() |
| 1290 | |
| 1291 | private lazy var longPressGesture: UILongPressGestureRecognizer = { |
| 1292 | let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) |
| 1293 | gesture.delegate = self |
| 1294 | gesture.cancelsTouchesInView = false |
| 1295 | gesture.minimumPressDuration = 0.28 |
| 1296 | return gesture |
| 1297 | }() |
| 1298 | |
| 1299 | override init(frame: CGRect) { |
| 1300 | super.init(frame: frame) |
| 1301 | isOpaque = true |
| 1302 | contentMode = .redraw |
| 1303 | addGestureRecognizer(horizontalPanGesture) |
| 1304 | addGestureRecognizer(tapGesture) |
| 1305 | addGestureRecognizer(longPressGesture) |
| 1306 | tapGesture.require(toFail: longPressGesture) |
| 1307 | } |
| 1308 | |
| 1309 | func invalidateVisibleViewport() { |
| 1310 | setNeedsDisplayForVisibleBounds() |
| 1311 | } |
| 1312 | |
| 1313 | private func setNeedsDisplayForVisibleBounds() { |
| 1314 | setNeedsDisplay() |
| 1315 | } |
| 1316 | |
| 1317 | required init?(coder: NSCoder) { |
| 1318 | super.init(coder: coder) |
| 1319 | isOpaque = true |
| 1320 | contentMode = .redraw |
| 1321 | addGestureRecognizer(horizontalPanGesture) |
| 1322 | addGestureRecognizer(tapGesture) |
| 1323 | addGestureRecognizer(longPressGesture) |
| 1324 | tapGesture.require(toFail: longPressGesture) |
| 1325 | } |
| 1326 | |
| 1327 | override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { |
| 1328 | guard gestureRecognizer === horizontalPanGesture else { |
| 1329 | return true |
| 1330 | } |
| 1331 | |
| 1332 | let velocity = horizontalPanGesture.velocity(in: self) |
| 1333 | guard abs(velocity.x) > abs(velocity.y) * 1.25 else { |
| 1334 | return false |
| 1335 | } |
| 1336 | |
| 1337 | guard let panTarget = horizontalPanTarget(at: horizontalPanGesture.location(in: self)) else { |
| 1338 | return false |
| 1339 | } |
| 1340 | |
| 1341 | let currentOffset = horizontalOffset(for: panTarget.fileId, kind: panTarget.kind) |
| 1342 | if velocity.x > 0 && currentOffset <= 0.5 { |
| 1343 | return false |
| 1344 | } |
| 1345 | |
| 1346 | return maxHorizontalOffset(for: panTarget) > 0 |
| 1347 | } |
| 1348 | |
| 1349 | @objc private func handleTap(_ gesture: UITapGestureRecognizer) { |
| 1350 | guard gesture.state == .ended else { |
| 1351 | return |
| 1352 | } |
| 1353 | |
| 1354 | let point = gesture.location(in: self) |
| 1355 | if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) { |
| 1356 | handleFileHeaderTap(row: stickyHeader.row, rect: stickyHeader.rect, point: point) |
| 1357 | return |
| 1358 | } |
| 1359 | |
| 1360 | guard let rowIndex = rowIndex(at: verticalOffset + point.y), |
| 1361 | rows.indices.contains(rowIndex) else { |
| 1362 | return |
| 1363 | } |
| 1364 | |
| 1365 | let row = rows[rowIndex] |
| 1366 | if row.kind == "comment" { |
| 1367 | onToggleComment?(row.id) |
| 1368 | return |
| 1369 | } |
| 1370 | |
| 1371 | if row.kind == "line" { |
| 1372 | onPressLine?(linePressPayload(for: row, gesture: "tap")) |
| 1373 | return |
| 1374 | } |
| 1375 | |
| 1376 | guard row.kind == "file" else { |
| 1377 | return |
| 1378 | } |
| 1379 | |
| 1380 | let rowY = rowOffsets[rowIndex] - verticalOffset |
| 1381 | let rect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) |
| 1382 | handleFileHeaderTap(row: row, rect: rect, point: point) |
| 1383 | } |
| 1384 | |
| 1385 | @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { |
| 1386 | guard gesture.state == .began else { |
| 1387 | return |
| 1388 | } |
| 1389 | |
| 1390 | let point = gesture.location(in: self) |
| 1391 | if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) { |
| 1392 | return |
| 1393 | } |
| 1394 | |
| 1395 | guard let rowIndex = rowIndex(at: verticalOffset + point.y), |
| 1396 | rows.indices.contains(rowIndex) else { |
| 1397 | return |
| 1398 | } |
| 1399 | |
| 1400 | let row = rows[rowIndex] |
| 1401 | guard row.kind == "line" else { |
| 1402 | return |
| 1403 | } |
| 1404 | |
| 1405 | onPressLine?(linePressPayload(for: row, gesture: "longPress")) |
| 1406 | } |
| 1407 | |
| 1408 | private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] { |
| 1409 | var payload: [String: Any] = [ |
| 1410 | "rowId": row.id, |
| 1411 | "fileId": resolvedFileId(for: row), |
| 1412 | "gesture": gesture |
| 1413 | ] |
| 1414 | |
| 1415 | if let oldLineNumber = row.oldLineNumber { |
| 1416 | payload["oldLineNumber"] = oldLineNumber |
| 1417 | } |
| 1418 | if let newLineNumber = row.newLineNumber { |
| 1419 | payload["newLineNumber"] = newLineNumber |
| 1420 | } |
| 1421 | if let change = row.change { |
| 1422 | payload["change"] = change |
| 1423 | } |
| 1424 | |
| 1425 | return payload |
| 1426 | } |
| 1427 | |
| 1428 | private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) { |
| 1429 | let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect) |
| 1430 | let fileId = resolvedFileId(for: row) |
| 1431 | |
| 1432 | if interactiveRects.checkbox.contains(point) { |
| 1433 | onToggleViewedFile?(fileId) |
| 1434 | return |
| 1435 | } |
| 1436 | |
| 1437 | if rect.contains(point) { |
| 1438 | onToggleFile?(fileId) |
| 1439 | } |
| 1440 | } |
| 1441 | |
| 1442 | func gestureRecognizer( |
| 1443 | _ gestureRecognizer: UIGestureRecognizer, |
| 1444 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer |
| 1445 | ) -> Bool { |
| 1446 | false |
| 1447 | } |
| 1448 | |
| 1449 | @objc private func handleHorizontalPan(_ gesture: UIPanGestureRecognizer) { |
| 1450 | switch gesture.state { |
| 1451 | case .began: |
| 1452 | stopHorizontalDeceleration() |
| 1453 | let panTarget = horizontalPanTarget(at: gesture.location(in: self)) |
| 1454 | activePanFileId = panTarget?.fileId |
| 1455 | activePanKind = panTarget?.kind |
| 1456 | panStartHorizontalOffset = horizontalOffset(for: panTarget?.fileId, kind: panTarget?.kind) |
| 1457 | case .changed, .ended, .cancelled: |
| 1458 | guard let activePanFileId, let activePanKind else { |
| 1459 | return |
| 1460 | } |
| 1461 | let translation = gesture.translation(in: self) |
| 1462 | setHorizontalOffset( |
| 1463 | min( |
| 1464 | max(panStartHorizontalOffset - translation.x, 0), |
| 1465 | maxHorizontalOffset(for: (fileId: activePanFileId, kind: activePanKind)) |
| 1466 | ), |
| 1467 | for: activePanFileId, |
| 1468 | kind: activePanKind |
| 1469 | ) |
| 1470 | if gesture.state == .ended, activePanKind == .code { |
| 1471 | let velocity = -gesture.velocity(in: self).x |
| 1472 | self.activePanFileId = nil |
| 1473 | self.activePanKind = nil |
| 1474 | startHorizontalDeceleration(fileId: activePanFileId, velocity: velocity) |
| 1475 | } else if gesture.state == .cancelled { |
| 1476 | self.activePanFileId = nil |
| 1477 | self.activePanKind = nil |
| 1478 | } else if gesture.state == .ended { |
| 1479 | self.activePanFileId = nil |
| 1480 | self.activePanKind = nil |
| 1481 | } |
| 1482 | default: |
| 1483 | break |
| 1484 | } |
| 1485 | } |
| 1486 | |
| 1487 | private func horizontalPanTarget(at point: CGPoint) -> (fileId: String, kind: ReviewDiffHorizontalPanKind)? { |
| 1488 | if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) { |
| 1489 | return (resolvedFileId(for: stickyHeader.row), .fileHeaderPath) |
| 1490 | } |
| 1491 | |
| 1492 | guard let row = row(at: point) else { |
| 1493 | guard verticalOffset + point.y >= contentHeight, |
| 1494 | fileHeaderRowIndices.isEmpty, |
| 1495 | contentWidthsByFileId.count == 1, |
| 1496 | let fileId = contentWidthsByFileId.keys.first, |
| 1497 | !collapsedFileIds.contains(fileId) else { |
| 1498 | return nil |
| 1499 | } |
| 1500 | return (fileId, .code) |
| 1501 | } |
| 1502 | |
| 1503 | let fileId = resolvedFileId(for: row) |
| 1504 | if row.kind == "file" { |
| 1505 | return (fileId, .fileHeaderPath) |
| 1506 | } |
| 1507 | |
| 1508 | return (fileId, .code) |
| 1509 | } |
| 1510 | |
| 1511 | private func stickyFileHeaderTarget() -> ReviewDiffStickyFileHeaderTarget? { |
| 1512 | guard let stickyHeader = stickyFileHeaderRowIndex() else { |
| 1513 | return nil |
| 1514 | } |
| 1515 | |
| 1516 | let rowIndex = stickyHeader.rowIndex |
| 1517 | let headerTop = rowOffsets[rowIndex] |
| 1518 | guard headerTop < verticalOffset + topContentInset else { |
| 1519 | return nil |
| 1520 | } |
| 1521 | |
| 1522 | let nextHeaderPosition = stickyHeader.position + 1 |
| 1523 | let nextHeaderRowIndex = fileHeaderRowIndices.indices.contains(nextHeaderPosition) |
| 1524 | ? fileHeaderRowIndices[nextHeaderPosition] |
| 1525 | : nil |
| 1526 | let pushedY: CGFloat |
| 1527 | if let nextHeaderRowIndex { |
| 1528 | pushedY = min(topContentInset, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight) |
| 1529 | } else { |
| 1530 | pushedY = topContentInset |
| 1531 | } |
| 1532 | |
| 1533 | guard pushedY > topContentInset - style.fileHeaderHeight else { |
| 1534 | return nil |
| 1535 | } |
| 1536 | |
| 1537 | let rect = CGRect( |
| 1538 | x: 0, |
| 1539 | y: pushedY, |
| 1540 | width: max(bounds.width, viewportWidth), |
| 1541 | height: style.fileHeaderHeight |
| 1542 | ) |
| 1543 | return ReviewDiffStickyFileHeaderTarget(rowIndex: rowIndex, row: rows[rowIndex], rect: rect) |
| 1544 | } |
| 1545 | |
| 1546 | private func stickyFileHeaderRowIndex() -> ReviewDiffStickyFileHeaderIndex? { |
| 1547 | guard !fileHeaderRowIndices.isEmpty else { |
| 1548 | return nil |
| 1549 | } |
| 1550 | |
| 1551 | var lowerBound = 0 |
| 1552 | var upperBound = fileHeaderRowIndices.count |
| 1553 | while lowerBound < upperBound { |
| 1554 | let midpoint = (lowerBound + upperBound) / 2 |
| 1555 | let rowIndex = fileHeaderRowIndices[midpoint] |
| 1556 | if rowOffsets[rowIndex] <= verticalOffset + topContentInset { |
| 1557 | lowerBound = midpoint + 1 |
| 1558 | } else { |
| 1559 | upperBound = midpoint |
| 1560 | } |
| 1561 | } |
| 1562 | |
| 1563 | let matchIndex = lowerBound - 1 |
| 1564 | guard matchIndex >= 0 else { |
| 1565 | return nil |
| 1566 | } |
| 1567 | return ReviewDiffStickyFileHeaderIndex(position: matchIndex, rowIndex: fileHeaderRowIndices[matchIndex]) |
| 1568 | } |
| 1569 | |
| 1570 | func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? { |
| 1571 | if let stickyHeader = stickyFileHeaderTarget(), |
| 1572 | resolvedFileId(for: stickyHeader.row) == fileId { |
| 1573 | return ReviewDiffScrollAnchor(fileId: fileId, screenY: topContentInset) |
| 1574 | } |
| 1575 | |
| 1576 | guard let headerOffset = fileHeaderOffset(forFileId: fileId) else { |
| 1577 | return nil |
| 1578 | } |
| 1579 | |
| 1580 | return ReviewDiffScrollAnchor(fileId: fileId, screenY: headerOffset - verticalOffset) |
| 1581 | } |
| 1582 | |
| 1583 | func fileHeaderOffset(forFileId fileId: String) -> CGFloat? { |
| 1584 | guard let rowIndex = fileHeaderRowIndex(forFileId: fileId) else { |
| 1585 | return nil |
| 1586 | } |
| 1587 | |
| 1588 | return rowOffsets[rowIndex] |
| 1589 | } |
| 1590 | |
| 1591 | func visibleFileId(atVerticalOffset verticalOffset: CGFloat) -> String? { |
| 1592 | guard let firstHeaderRowIndex = fileHeaderRowIndices.first else { |
| 1593 | return nil |
| 1594 | } |
| 1595 | |
| 1596 | var lowerBound = 0 |
| 1597 | var upperBound = fileHeaderRowIndices.count |
| 1598 | while lowerBound < upperBound { |
| 1599 | let midpoint = (lowerBound + upperBound) / 2 |
| 1600 | let rowIndex = fileHeaderRowIndices[midpoint] |
| 1601 | if rowOffsets[rowIndex] <= verticalOffset + 0.5 { |
| 1602 | lowerBound = midpoint + 1 |
| 1603 | } else { |
| 1604 | upperBound = midpoint |
| 1605 | } |
| 1606 | } |
| 1607 | |
| 1608 | let rowIndex = lowerBound > 0 |
| 1609 | ? fileHeaderRowIndices[lowerBound - 1] |
| 1610 | : firstHeaderRowIndex |
| 1611 | guard rows.indices.contains(rowIndex) else { |
| 1612 | return nil |
| 1613 | } |
| 1614 | return resolvedFileId(for: rows[rowIndex]) |
| 1615 | } |
| 1616 | |
| 1617 | private func fileHeaderRowIndex(forFileId fileId: String) -> Int? { |
| 1618 | fileHeaderRowIndices.first { rowIndex in |
| 1619 | rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId |
| 1620 | } |
| 1621 | } |
| 1622 | |
| 1623 | private func row(at point: CGPoint) -> ReviewDiffNativeRow? { |
| 1624 | guard let rowIndex = rowIndex(at: verticalOffset + point.y) else { |
| 1625 | return nil |
| 1626 | } |
| 1627 | guard rows.indices.contains(rowIndex) else { |
| 1628 | return nil |
| 1629 | } |
| 1630 | return rows[rowIndex] |
| 1631 | } |
| 1632 | |
| 1633 | private func rowIndex(at absoluteY: CGFloat) -> Int? { |
| 1634 | guard !rows.isEmpty else { |
| 1635 | return nil |
| 1636 | } |
| 1637 | |
| 1638 | var lowerBound = 0 |
| 1639 | var upperBound = rows.count - 1 |
| 1640 | while lowerBound <= upperBound { |
| 1641 | let midpoint = (lowerBound + upperBound) / 2 |
| 1642 | let rowStart = rowOffsets[midpoint] |
| 1643 | let rowEnd = rowStart + height(at: midpoint) |
| 1644 | |
| 1645 | if absoluteY < rowStart { |
| 1646 | upperBound = midpoint - 1 |
| 1647 | } else if absoluteY >= rowEnd { |
| 1648 | lowerBound = midpoint + 1 |
| 1649 | } else { |
| 1650 | return midpoint |
| 1651 | } |
| 1652 | } |
| 1653 | |
| 1654 | return nil |
| 1655 | } |
| 1656 | |
| 1657 | private func firstVisibleRowIndex(atOrAfter absoluteY: CGFloat) -> Int? { |
| 1658 | guard !rows.isEmpty else { |
| 1659 | return nil |
| 1660 | } |
| 1661 | |
| 1662 | var lowerBound = 0 |
| 1663 | var upperBound = rows.count |
| 1664 | while lowerBound < upperBound { |
| 1665 | let midpoint = (lowerBound + upperBound) / 2 |
| 1666 | let rowEnd = rowOffsets[midpoint] + height(at: midpoint) |
| 1667 | |
| 1668 | if rowEnd < absoluteY { |
| 1669 | lowerBound = midpoint + 1 |
| 1670 | } else { |
| 1671 | upperBound = midpoint |
| 1672 | } |
| 1673 | } |
| 1674 | |
| 1675 | return lowerBound < rows.count ? lowerBound : nil |
| 1676 | } |
| 1677 | |
| 1678 | private func lastVisibleRowIndex(atOrBefore absoluteY: CGFloat) -> Int? { |
| 1679 | guard !rows.isEmpty else { |
| 1680 | return nil |
| 1681 | } |
| 1682 | |
| 1683 | var lowerBound = 0 |
| 1684 | var upperBound = rows.count |
| 1685 | while lowerBound < upperBound { |
| 1686 | let midpoint = (lowerBound + upperBound) / 2 |
| 1687 | let rowStart = rowOffsets[midpoint] |
| 1688 | |
| 1689 | if rowStart <= absoluteY { |
| 1690 | lowerBound = midpoint + 1 |
| 1691 | } else { |
| 1692 | upperBound = midpoint |
| 1693 | } |
| 1694 | } |
| 1695 | |
| 1696 | let index = lowerBound - 1 |
| 1697 | return index >= 0 ? index : nil |
| 1698 | } |
| 1699 | |
| 1700 | private func resolvedFileId(for row: ReviewDiffNativeRow) -> String { |
| 1701 | if let fileId = row.fileId { |
| 1702 | return fileId |
| 1703 | } |
| 1704 | if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") { |
| 1705 | return String(row.id[..<range.lowerBound]) |
| 1706 | } |
| 1707 | return row.filePath ?? row.id |
| 1708 | } |
| 1709 | |
| 1710 | private func horizontalOffset( |
| 1711 | for fileId: String?, |
| 1712 | kind: ReviewDiffHorizontalPanKind? = .code |
| 1713 | ) -> CGFloat { |
| 1714 | guard let fileId else { |
| 1715 | return 0 |
| 1716 | } |
| 1717 | if kind == .fileHeaderPath { |
| 1718 | return headerPathOffsetsByFileId[fileId] ?? 0 |
| 1719 | } |
| 1720 | return horizontalOffsetsByFileId[fileId] ?? 0 |
| 1721 | } |
| 1722 | |
| 1723 | private func setHorizontalOffset( |
| 1724 | _ offset: CGFloat, |
| 1725 | for fileId: String, |
| 1726 | kind: ReviewDiffHorizontalPanKind = .code |
| 1727 | ) { |
| 1728 | if kind == .fileHeaderPath { |
| 1729 | headerPathOffsetsByFileId[fileId] = offset |
| 1730 | setNeedsDisplayForVisibleBounds() |
| 1731 | return |
| 1732 | } |
| 1733 | horizontalOffsetsByFileId[fileId] = offset |
| 1734 | setNeedsDisplayForVisibleBounds() |
| 1735 | } |
| 1736 | |
| 1737 | private func clampHorizontalOffsets() { |
| 1738 | for (fileId, offset) in horizontalOffsetsByFileId { |
| 1739 | horizontalOffsetsByFileId[fileId] = min(offset, maxHorizontalOffset(for: (fileId: fileId, kind: .code))) |
| 1740 | } |
| 1741 | for (fileId, offset) in headerPathOffsetsByFileId { |
| 1742 | headerPathOffsetsByFileId[fileId] = min( |
| 1743 | offset, |
| 1744 | maxHorizontalOffset(for: (fileId: fileId, kind: .fileHeaderPath)) |
| 1745 | ) |
| 1746 | } |
| 1747 | } |
| 1748 | |
| 1749 | private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat { |
| 1750 | if target.kind == .fileHeaderPath, |
| 1751 | let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { |
| 1752 | return maxHeaderPathOffset(for: row) |
| 1753 | } |
| 1754 | if codeWrapColumns != nil { |
| 1755 | return 0 |
| 1756 | } |
| 1757 | |
| 1758 | return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) |
| 1759 | } |
| 1760 | |
| 1761 | private func contentWidth(for fileId: String) -> CGFloat { |
| 1762 | contentWidthsByFileId[fileId] ?? min(style.contentWidth, max(viewportWidth, 0)) |
| 1763 | } |
| 1764 | |
| 1765 | func currentVisibleRowRange() -> (firstRowIndex: Int, lastRowIndex: Int)? { |
| 1766 | guard !rows.isEmpty else { |
| 1767 | return nil |
| 1768 | } |
| 1769 | |
| 1770 | let visibleMinY = verticalOffset |
| 1771 | let visibleMaxY = verticalOffset + max(bounds.height, 1) |
| 1772 | guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: visibleMinY), |
| 1773 | let lastRowIndex = lastVisibleRowIndex(atOrBefore: visibleMaxY), |
| 1774 | firstRowIndex <= lastRowIndex else { |
| 1775 | return nil |
| 1776 | } |
| 1777 | |
| 1778 | return (firstRowIndex: firstRowIndex, lastRowIndex: lastRowIndex) |
| 1779 | } |
| 1780 | |
| 1781 | private func maxHeaderPathOffset(for row: ReviewDiffNativeRow) -> CGFloat { |
| 1782 | let fullRect = CGRect(x: 0, y: 0, width: max(bounds.width, viewportWidth), height: height(for: row)) |
| 1783 | let layout = fileHeaderPathLayout(for: row, cardRect: fullRect) |
| 1784 | let pathWidth = textWidth(layout.displayPath, font: fileHeaderFont) |
| 1785 | return max(0, pathWidth - layout.rect.width) |
| 1786 | } |
| 1787 | |
| 1788 | private func startHorizontalDeceleration(fileId: String, velocity: CGFloat) { |
| 1789 | guard abs(velocity) > 80 else { |
| 1790 | return |
| 1791 | } |
| 1792 | |
| 1793 | let currentOffset = horizontalOffset(for: fileId) |
| 1794 | let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code)) |
| 1795 | if (currentOffset <= 0 && velocity < 0) || (currentOffset >= maxOffset && velocity > 0) { |
| 1796 | return |
| 1797 | } |
| 1798 | |
| 1799 | stopHorizontalDeceleration() |
| 1800 | deceleratingFileId = fileId |
| 1801 | horizontalVelocity = velocity |
| 1802 | lastDecelerationTimestamp = 0 |
| 1803 | |
| 1804 | let displayLink = CADisplayLink(target: self, selector: #selector(stepHorizontalDeceleration(_:))) |
| 1805 | displayLink.add(to: .main, forMode: .common) |
| 1806 | decelerationDisplayLink = displayLink |
| 1807 | } |
| 1808 | |
| 1809 | @objc private func stepHorizontalDeceleration(_ displayLink: CADisplayLink) { |
| 1810 | guard let fileId = deceleratingFileId else { |
| 1811 | stopHorizontalDeceleration() |
| 1812 | return |
| 1813 | } |
| 1814 | |
| 1815 | if lastDecelerationTimestamp == 0 { |
| 1816 | lastDecelerationTimestamp = displayLink.timestamp |
| 1817 | return |
| 1818 | } |
| 1819 | |
| 1820 | let dt = max(0, displayLink.timestamp - lastDecelerationTimestamp) |
| 1821 | lastDecelerationTimestamp = displayLink.timestamp |
| 1822 | |
| 1823 | let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code)) |
| 1824 | let nextOffset = horizontalOffset(for: fileId) + horizontalVelocity * CGFloat(dt) |
| 1825 | let clampedOffset = min(max(nextOffset, 0), maxOffset) |
| 1826 | setHorizontalOffset(clampedOffset, for: fileId) |
| 1827 | |
| 1828 | // UIScrollView deceleration rates are expressed per millisecond. |
| 1829 | horizontalVelocity *= CGFloat(pow(Double(UIScrollView.DecelerationRate.normal.rawValue), dt * 1000)) |
| 1830 | |
| 1831 | if abs(horizontalVelocity) < 20 || clampedOffset <= 0 || clampedOffset >= maxOffset { |
| 1832 | stopHorizontalDeceleration() |
| 1833 | } |
| 1834 | } |
| 1835 | |
| 1836 | private func stopHorizontalDeceleration() { |
| 1837 | decelerationDisplayLink?.invalidate() |
| 1838 | decelerationDisplayLink = nil |
| 1839 | deceleratingFileId = nil |
| 1840 | horizontalVelocity = 0 |
| 1841 | lastDecelerationTimestamp = 0 |
| 1842 | } |
| 1843 | |
| 1844 | deinit { |
| 1845 | stopHorizontalDeceleration() |
| 1846 | } |
| 1847 | |
| 1848 | override func draw(_ rect: CGRect) { |
| 1849 | let drawStartedAt = CACurrentMediaTime() |
| 1850 | guard let context = UIGraphicsGetCurrentContext() else { |
| 1851 | return |
| 1852 | } |
| 1853 | |
| 1854 | theme.background.setFill() |
| 1855 | context.fill(rect) |
| 1856 | |
| 1857 | if rows.isEmpty { |
| 1858 | drawEmptyState(rect) |
| 1859 | return |
| 1860 | } |
| 1861 | |
| 1862 | let visibleMinY = verticalOffset + rect.minY |
| 1863 | let visibleMaxY = verticalOffset + rect.maxY |
| 1864 | let overscan = max(style.rowHeight, style.fileHeaderHeight) * 4 |
| 1865 | let rangeMinY = max(0, visibleMinY - overscan) |
| 1866 | let rangeMaxY = visibleMaxY + overscan |
| 1867 | guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: rangeMinY), |
| 1868 | let lastRowIndex = lastVisibleRowIndex(atOrBefore: rangeMaxY), |
| 1869 | firstRowIndex <= lastRowIndex else { |
| 1870 | return |
| 1871 | } |
| 1872 | |
| 1873 | var drawnRowCount = 0 |
| 1874 | for rowIndex in firstRowIndex...lastRowIndex { |
| 1875 | let rowStart = rowOffsets[rowIndex] |
| 1876 | let rowHeight = height(at: rowIndex) |
| 1877 | if rowHeight <= 0 { |
| 1878 | continue |
| 1879 | } |
| 1880 | let rowEnd = rowStart + rowHeight |
| 1881 | if rowEnd < visibleMinY || rowStart > visibleMaxY { |
| 1882 | continue |
| 1883 | } |
| 1884 | drawRow(rows[rowIndex], rowIndex: rowIndex, context: context) |
| 1885 | drawnRowCount += 1 |
| 1886 | } |
| 1887 | drawStickyFileHeader(context: context) |
| 1888 | |
| 1889 | maybeEmitDrawMetrics( |
| 1890 | drawnRowCount: drawnRowCount, |
| 1891 | durationMs: (CACurrentMediaTime() - drawStartedAt) * 1000, |
| 1892 | firstRowIndex: firstRowIndex, |
| 1893 | lastRowIndex: lastRowIndex |
| 1894 | ) |
| 1895 | } |
| 1896 | |
| 1897 | private func maybeEmitDrawMetrics( |
| 1898 | drawnRowCount: Int, |
| 1899 | durationMs: Double, |
| 1900 | firstRowIndex: Int, |
| 1901 | lastRowIndex: Int |
| 1902 | ) { |
| 1903 | guard !isVerticalScrollActive else { |
| 1904 | return |
| 1905 | } |
| 1906 | |
| 1907 | let now = CACurrentMediaTime() |
| 1908 | guard now - lastDrawMetricsTimestamp >= 1 else { |
| 1909 | return |
| 1910 | } |
| 1911 | |
| 1912 | lastDrawMetricsTimestamp = now |
| 1913 | onDrawMetrics?([ |
| 1914 | "drawnRows": drawnRowCount, |
| 1915 | "durationMs": durationMs, |
| 1916 | "firstRowIndex": firstRowIndex, |
| 1917 | "lastRowIndex": lastRowIndex, |
| 1918 | "scannedRows": lastRowIndex - firstRowIndex + 1, |
| 1919 | "totalRows": rows.count, |
| 1920 | ]) |
| 1921 | } |
| 1922 | |
| 1923 | private func drawEmptyState(_ rect: CGRect) { |
| 1924 | let message = "No native diff rows" |
| 1925 | let attributes: [NSAttributedString.Key: Any] = [ |
| 1926 | .font: emptyStateFont, |
| 1927 | .foregroundColor: theme.mutedText, |
| 1928 | ] |
| 1929 | message.draw(at: CGPoint(x: 16, y: rect.minY + 16), withAttributes: attributes) |
| 1930 | } |
| 1931 | |
| 1932 | private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { |
| 1933 | let rowY = rowOffsets[rowIndex] - verticalOffset |
| 1934 | let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex)) |
| 1935 | |
| 1936 | switch row.kind { |
| 1937 | case "file": |
| 1938 | drawFileRow(row, rect: fullRect, context: context) |
| 1939 | case "hunk": |
| 1940 | drawHunkRow(row, rect: fullRect, context: context) |
| 1941 | case "notice": |
| 1942 | drawNoticeRow(row, rect: fullRect, context: context) |
| 1943 | case "comment": |
| 1944 | drawCommentRow(row, rect: fullRect, context: context) |
| 1945 | default: |
| 1946 | drawCodeRow(row, rect: fullRect, context: context) |
| 1947 | } |
| 1948 | } |
| 1949 | |
| 1950 | private func drawStickyFileHeader(context: CGContext) { |
| 1951 | guard let stickyHeader = stickyFileHeaderTarget() else { |
| 1952 | return |
| 1953 | } |
| 1954 | |
| 1955 | drawFileRow(stickyHeader.row, rect: stickyHeader.rect, context: context) |
| 1956 | } |
| 1957 | |
| 1958 | private func drawFileRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { |
| 1959 | theme.background.setFill() |
| 1960 | context.fill(rect) |
| 1961 | |
| 1962 | let cardRect = rect |
| 1963 | theme.headerBackground.setFill() |
| 1964 | context.fill(cardRect) |
| 1965 | |
| 1966 | let hairline = 1 / traitCollection.displayScale |
| 1967 | theme.border.setFill() |
| 1968 | context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline)) |
| 1969 | |
| 1970 | let centerY = cardRect.midY |
| 1971 | let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect) |
| 1972 | let fileId = resolvedFileId(for: row) |
| 1973 | drawDisclosureChevron( |
| 1974 | rect: interactiveRects.chevron, |
| 1975 | color: theme.mutedText, |
| 1976 | collapsed: collapsedFileIds.contains(fileId) |
| 1977 | ) |
| 1978 | |
| 1979 | drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType) |
| 1980 | |
| 1981 | drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId)) |
| 1982 | |
| 1983 | let deletions = row.deletions ?? 0 |
| 1984 | let additions = row.additions ?? 0 |
| 1985 | let deleteText = "-\(deletions)" |
| 1986 | let addText = "+\(additions)" |
| 1987 | let deleteWidth = textWidth(deleteText, font: fileHeaderMetaFont) |
| 1988 | let addWidth = textWidth(addText, font: fileHeaderMetaFont) |
| 1989 | let countsGap = min(style.fileHeaderCountGap, 4) |
| 1990 | let countsWidth = deleteWidth + countsGap + addWidth |
| 1991 | let countsX = interactiveRects.checkbox.minX - 10 - countsWidth |
| 1992 | drawSingleLineText( |
| 1993 | deleteText, |
| 1994 | rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18), |
| 1995 | color: theme.deleteText, |
| 1996 | font: fileHeaderMetaFont |
| 1997 | ) |
| 1998 | drawSingleLineText( |
| 1999 | addText, |
| 2000 | rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18), |
| 2001 | color: theme.addText, |
| 2002 | font: fileHeaderMetaFont |
| 2003 | ) |
| 2004 | |
| 2005 | let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect) |
| 2006 | let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath) |
| 2007 | drawSingleLineText( |
| 2008 | pathLayout.displayPath, |
| 2009 | rect: pathLayout.rect, |
| 2010 | color: theme.text, |
| 2011 | font: fileHeaderFont, |
| 2012 | horizontalOffset: pathOffset |
| 2013 | ) |
| 2014 | drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context) |
| 2015 | } |
| 2016 | |
| 2017 | private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { |
| 2018 | guard !collapsedFileIds.contains(resolvedFileId(for: row)) else { |
| 2019 | return |
| 2020 | } |
| 2021 | |
| 2022 | theme.background.setFill() |
| 2023 | context.fill(rect) |
| 2024 | |
| 2025 | let hairline = 1 / traitCollection.displayScale |
| 2026 | theme.border.withAlphaComponent(0.65).setFill() |
| 2027 | context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline)) |
| 2028 | |
| 2029 | let iconSize: CGFloat = 16 |
| 2030 | let iconRect = CGRect( |
| 2031 | x: style.fileHeaderHorizontalPadding + 2, |
| 2032 | y: rect.midY - iconSize / 2, |
| 2033 | width: iconSize, |
| 2034 | height: iconSize |
| 2035 | ) |
| 2036 | drawNoticeIcon(rect: iconRect, color: theme.mutedText) |
| 2037 | |
| 2038 | drawSingleLineText( |
| 2039 | row.text ?? "", |
| 2040 | rect: CGRect( |
| 2041 | x: iconRect.maxX + 10, |
| 2042 | y: rect.midY - fileHeaderSubtextFont.lineHeight / 2, |
| 2043 | width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding), |
| 2044 | height: fileHeaderSubtextFont.lineHeight |
| 2045 | ), |
| 2046 | color: theme.mutedText, |
| 2047 | font: fileHeaderSubtextFont |
| 2048 | ) |
| 2049 | } |
| 2050 | |
| 2051 | private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { |
| 2052 | guard !collapsedFileIds.contains(resolvedFileId(for: row)) else { |
| 2053 | return |
| 2054 | } |
| 2055 | |
| 2056 | theme.background.setFill() |
| 2057 | context.fill(rect) |
| 2058 | |
| 2059 | let isCollapsed = collapsedCommentIds.contains(row.id) |
| 2060 | let cardInset = CGFloat(8) |
| 2061 | let cardRect = rect.insetBy(dx: cardInset, dy: 5) |
| 2062 | let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10) |
| 2063 | theme.headerBackground.setFill() |
| 2064 | cardPath.fill() |
| 2065 | theme.border.withAlphaComponent(0.85).setStroke() |
| 2066 | cardPath.lineWidth = 1 |
| 2067 | cardPath.stroke() |
| 2068 | |
| 2069 | let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16) |
| 2070 | drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed) |
| 2071 | |
| 2072 | let title = "Comment on \(row.commentRangeLabel ?? "line")" |
| 2073 | drawSingleLineText( |
| 2074 | title, |
| 2075 | rect: CGRect( |
| 2076 | x: chevronRect.maxX + 10, |
| 2077 | y: cardRect.minY + 8, |
| 2078 | width: max(24, cardRect.width - chevronRect.maxX - 28), |
| 2079 | height: fileHeaderSubtextFont.lineHeight + 4 |
| 2080 | ), |
| 2081 | color: theme.mutedText, |
| 2082 | font: fileHeaderSubtextFont |
| 2083 | ) |
| 2084 | |
| 2085 | guard !isCollapsed else { |
| 2086 | return |
| 2087 | } |
| 2088 | |
| 2089 | let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" |
| 2090 | drawMultilineText( |
| 2091 | body.isEmpty ? "Comment" : body, |
| 2092 | rect: CGRect( |
| 2093 | x: cardRect.minX + 18, |
| 2094 | y: cardRect.minY + 42, |
| 2095 | width: max(24, cardRect.width - 36), |
| 2096 | height: max(20, cardRect.height - 56) |
| 2097 | ), |
| 2098 | color: theme.text, |
| 2099 | font: fileHeaderSubtextFont, |
| 2100 | maximumLineCount: 3 |
| 2101 | ) |
| 2102 | } |
| 2103 | |
| 2104 | private func fileHeaderPathLayout( |
| 2105 | for row: ReviewDiffNativeRow, |
| 2106 | cardRect: CGRect |
| 2107 | ) -> ReviewDiffFileHeaderPathLayout { |
| 2108 | let centerY = cardRect.midY |
| 2109 | let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect) |
| 2110 | |
| 2111 | let deletions = row.deletions ?? 0 |
| 2112 | let additions = row.additions ?? 0 |
| 2113 | let deleteWidth = textWidth("-\(deletions)", font: fileHeaderMetaFont) |
| 2114 | let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont) |
| 2115 | let countsGap = min(style.fileHeaderCountGap, 4) |
| 2116 | let countsWidth = deleteWidth + countsGap + addWidth |
| 2117 | let countsX = interactiveRects.checkbox.minX - 10 - countsWidth |
| 2118 | let pathX = interactiveRects.icon.maxX + 10 |
| 2119 | let pathWidth = max(CGFloat(24), countsX - pathX - 12) |
| 2120 | let displayPath: String |
| 2121 | if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath { |
| 2122 | displayPath = "\(previousPath) -> \(filePath)" |
| 2123 | } else { |
| 2124 | displayPath = row.filePath ?? "" |
| 2125 | } |
| 2126 | |
| 2127 | return ReviewDiffFileHeaderPathLayout( |
| 2128 | displayPath: displayPath, |
| 2129 | rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20) |
| 2130 | ) |
| 2131 | } |
| 2132 | |
| 2133 | private func fileHeaderInteractiveRects( |
| 2134 | for row: ReviewDiffNativeRow, |
| 2135 | cardRect: CGRect |
| 2136 | ) -> ReviewDiffFileHeaderInteractiveRects { |
| 2137 | let centerY = cardRect.midY |
| 2138 | let horizontalPadding = style.fileHeaderHorizontalPadding |
| 2139 | let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20) |
| 2140 | let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20) |
| 2141 | let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20) |
| 2142 | return ReviewDiffFileHeaderInteractiveRects( |
| 2143 | chevron: chevronRect, |
| 2144 | icon: iconRect, |
| 2145 | checkbox: checkboxRect |
| 2146 | ) |
| 2147 | } |
| 2148 | |
| 2149 | private func fileStatusColor(_ changeType: String?) -> UIColor { |
| 2150 | switch changeType { |
| 2151 | case "new": |
| 2152 | return theme.addText |
| 2153 | case "deleted": |
| 2154 | return theme.deleteText |
| 2155 | case "renamed", "rename-pure", "rename-changed": |
| 2156 | return theme.hunkText |
| 2157 | default: |
| 2158 | return theme.hunkText |
| 2159 | } |
| 2160 | } |
| 2161 | |
| 2162 | private func drawFileIcon(rect: CGRect, changeType: String?) { |
| 2163 | let color = fileStatusColor(changeType) |
| 2164 | let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6) |
| 2165 | color.setStroke() |
| 2166 | outerPath.lineWidth = 2 |
| 2167 | outerPath.stroke() |
| 2168 | |
| 2169 | if isRenameChange(changeType) { |
| 2170 | drawRenameChevronIcon(rect: rect.insetBy(dx: 4.5, dy: 5), color: color) |
| 2171 | return |
| 2172 | } |
| 2173 | |
| 2174 | let dotRect = CGRect(x: rect.midX - 3, y: rect.midY - 3, width: 6, height: 6) |
| 2175 | color.setFill() |
| 2176 | UIBezierPath(ovalIn: dotRect).fill() |
| 2177 | } |
| 2178 | |
| 2179 | private func isRenameChange(_ changeType: String?) -> Bool { |
| 2180 | changeType == "renamed" || changeType == "rename-pure" || changeType == "rename-changed" |
| 2181 | } |
| 2182 | |
| 2183 | private func drawDisclosureChevron(rect: CGRect, color: UIColor, collapsed: Bool) { |
| 2184 | guard let context = UIGraphicsGetCurrentContext() else { |
| 2185 | return |
| 2186 | } |
| 2187 | |
| 2188 | context.saveGState() |
| 2189 | context.setStrokeColor(color.cgColor) |
| 2190 | context.setLineWidth(2) |
| 2191 | context.setLineCap(.round) |
| 2192 | context.setLineJoin(.round) |
| 2193 | if collapsed { |
| 2194 | context.move(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.minY + rect.height * 0.28)) |
| 2195 | context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.60, y: rect.midY)) |
| 2196 | context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.maxY - rect.height * 0.28)) |
| 2197 | } else { |
| 2198 | context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.42)) |
| 2199 | context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.62)) |
| 2200 | context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.42)) |
| 2201 | } |
| 2202 | context.strokePath() |
| 2203 | context.restoreGState() |
| 2204 | } |
| 2205 | |
| 2206 | private func drawRenameChevronIcon(rect: CGRect, color: UIColor) { |
| 2207 | guard let context = UIGraphicsGetCurrentContext() else { |
| 2208 | return |
| 2209 | } |
| 2210 | |
| 2211 | context.saveGState() |
| 2212 | context.setStrokeColor(color.cgColor) |
| 2213 | context.setLineWidth(1.8) |
| 2214 | context.setLineCap(.round) |
| 2215 | context.setLineJoin(.round) |
| 2216 | |
| 2217 | let chevronWidth = min(rect.width * 0.28, 3.6) |
| 2218 | let chevronHeight = min(rect.height, 8) |
| 2219 | let gap = min(rect.width * 0.18, 2.4) |
| 2220 | let totalWidth = chevronWidth * 2 + gap |
| 2221 | let startX = rect.midX - totalWidth / 2 |
| 2222 | let topY = rect.midY - chevronHeight / 2 |
| 2223 | let bottomY = rect.midY + chevronHeight / 2 |
| 2224 | |
| 2225 | for x in [startX, startX + chevronWidth + gap] { |
| 2226 | context.move(to: CGPoint(x: x, y: topY)) |
| 2227 | context.addLine(to: CGPoint(x: x + chevronWidth, y: rect.midY)) |
| 2228 | context.addLine(to: CGPoint(x: x, y: bottomY)) |
| 2229 | } |
| 2230 | |
| 2231 | context.strokePath() |
| 2232 | context.restoreGState() |
| 2233 | } |
| 2234 | |
| 2235 | private func drawViewedCheckbox(rect: CGRect, checked: Bool) { |
| 2236 | let path = UIBezierPath(roundedRect: rect, cornerRadius: 6) |
| 2237 | if checked { |
| 2238 | theme.hunkText.setFill() |
| 2239 | path.fill() |
| 2240 | } |
| 2241 | (checked ? theme.hunkText : theme.mutedText).setStroke() |
| 2242 | path.lineWidth = 1.8 |
| 2243 | path.stroke() |
| 2244 | |
| 2245 | guard checked, let context = UIGraphicsGetCurrentContext() else { |
| 2246 | return |
| 2247 | } |
| 2248 | |
| 2249 | context.saveGState() |
| 2250 | context.setStrokeColor(theme.background.cgColor) |
| 2251 | context.setLineWidth(2) |
| 2252 | context.setLineCap(.round) |
| 2253 | context.setLineJoin(.round) |
| 2254 | context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.midY)) |
| 2255 | context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.44, y: rect.maxY - rect.height * 0.30)) |
| 2256 | context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30)) |
| 2257 | context.strokePath() |
| 2258 | context.restoreGState() |
| 2259 | } |
| 2260 | |
| 2261 | private func drawNoticeIcon(rect: CGRect, color: UIColor) { |
| 2262 | guard let context = UIGraphicsGetCurrentContext() else { |
| 2263 | return |
| 2264 | } |
| 2265 | |
| 2266 | context.saveGState() |
| 2267 | context.setStrokeColor(color.cgColor) |
| 2268 | context.setLineWidth(1.7) |
| 2269 | context.setLineCap(.round) |
| 2270 | context.setLineJoin(.round) |
| 2271 | |
| 2272 | context.strokeEllipse(in: rect.insetBy(dx: 1, dy: 1)) |
| 2273 | context.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.30)) |
| 2274 | context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.58)) |
| 2275 | context.strokePath() |
| 2276 | color.setFill() |
| 2277 | context.fillEllipse(in: CGRect(x: rect.midX - 1, y: rect.maxY - rect.height * 0.30, width: 2, height: 2)) |
| 2278 | context.restoreGState() |
| 2279 | } |
| 2280 | |
| 2281 | private func drawCenteredText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { |
| 2282 | let paragraphStyle = NSMutableParagraphStyle() |
| 2283 | paragraphStyle.alignment = .center |
| 2284 | let attributes: [NSAttributedString.Key: Any] = [ |
| 2285 | .font: font, |
| 2286 | .foregroundColor: color, |
| 2287 | .paragraphStyle: paragraphStyle, |
| 2288 | ] |
| 2289 | (text as NSString).draw(in: rect, withAttributes: attributes) |
| 2290 | } |
| 2291 | |
| 2292 | private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { |
| 2293 | let fileId = resolvedFileId(for: row) |
| 2294 | let horizontalOffset = horizontalOffset(for: fileId) |
| 2295 | let contentWidth = contentWidth(for: fileId) |
| 2296 | theme.hunkBackground.setFill() |
| 2297 | context.fill(rect) |
| 2298 | |
| 2299 | context.saveGState() |
| 2300 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) |
| 2301 | drawText( |
| 2302 | row.text ?? "", |
| 2303 | rect: CGRect( |
| 2304 | x: codeStartX - horizontalOffset, |
| 2305 | y: centeredTextY(in: rect, font: hunkFont), |
| 2306 | width: contentWidth, |
| 2307 | height: hunkFont.lineHeight |
| 2308 | ), |
| 2309 | color: theme.hunkText, |
| 2310 | font: hunkFont |
| 2311 | ) |
| 2312 | context.restoreGState() |
| 2313 | } |
| 2314 | |
| 2315 | private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) { |
| 2316 | let fileId = resolvedFileId(for: row) |
| 2317 | let horizontalOffset = horizontalOffset(for: fileId) |
| 2318 | let contentWidth = contentWidth(for: fileId) |
| 2319 | let change = row.change ?? "context" |
| 2320 | // Wrapped rows keep the line number and first code line in the first row-height band. |
| 2321 | let layout = codeLayoutsByRowId[row.id] |
| 2322 | let firstLineRect = CGRect( |
| 2323 | x: rect.minX, y: rect.minY, width: rect.width, |
| 2324 | height: max(style.rowHeight, layout?.firstLineHeight ?? 0) |
| 2325 | ) |
| 2326 | rowBackground(for: change).setFill() |
| 2327 | context.fill(rect) |
| 2328 | |
| 2329 | if change == "add" { |
| 2330 | theme.addBar.setFill() |
| 2331 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) |
| 2332 | } else if change == "delete" { |
| 2333 | drawDeleteStripes( |
| 2334 | rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), |
| 2335 | context: context |
| 2336 | ) |
| 2337 | } |
| 2338 | |
| 2339 | drawSelectionOverlay(row, rect: rect, context: context) |
| 2340 | |
| 2341 | let lineNumber = row.newLineNumber ?? row.oldLineNumber |
| 2342 | if let lineNumber { |
| 2343 | drawRightAlignedText( |
| 2344 | "\(lineNumber)", |
| 2345 | rect: CGRect( |
| 2346 | x: style.changeBarWidth, |
| 2347 | y: centeredTextY(in: firstLineRect, font: lineNumberFont), |
| 2348 | width: style.gutterWidth - style.codePadding, |
| 2349 | height: lineNumberFont.lineHeight |
| 2350 | ), |
| 2351 | color: lineNumberColor(for: change), |
| 2352 | font: lineNumberFont |
| 2353 | ) |
| 2354 | } |
| 2355 | |
| 2356 | context.saveGState() |
| 2357 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) |
| 2358 | let codeTextRect = CGRect( |
| 2359 | x: codeStartX - horizontalOffset, |
| 2360 | y: centeredTextY(in: firstLineRect, font: codeFont), |
| 2361 | width: contentWidth, |
| 2362 | height: codeFont.lineHeight |
| 2363 | ) |
| 2364 | if let layout, layout.usesNativeLayout { |
| 2365 | let text = tokensByRowId[row.id].map { |
| 2366 | tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont) |
| 2367 | } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text]) |
| 2368 | let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : [] |
| 2369 | layout.decorate( |
| 2370 | text: text, |
| 2371 | highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map { |
| 2372 | NSRange(location: $0.start, length: $0.end - $0.start) |
| 2373 | }, |
| 2374 | color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), |
| 2375 | version: codeDecorationVersion |
| 2376 | ) |
| 2377 | layout.draw( |
| 2378 | at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)), |
| 2379 | clip: context.boundingBoxOfClipPath |
| 2380 | ) |
| 2381 | context.restoreGState() |
| 2382 | return |
| 2383 | } |
| 2384 | let lineStarts = layout?.starts ?? [0] |
| 2385 | drawWordDiffRanges( |
| 2386 | row, |
| 2387 | lineStarts: lineStarts, |
| 2388 | firstLineRect: firstLineRect, |
| 2389 | context: context, |
| 2390 | horizontalOffset: horizontalOffset |
| 2391 | ) |
| 2392 | if let tokens = tokensByRowId[row.id], !tokens.isEmpty { |
| 2393 | let attributedText = tokenAttributedString( |
| 2394 | rowId: row.id, |
| 2395 | tokens: tokens, |
| 2396 | fallbackColor: theme.text, |
| 2397 | font: codeFont |
| 2398 | ) |
| 2399 | drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in |
| 2400 | let segment = range.length == attributedText.length |
| 2401 | ? attributedText |
| 2402 | : attributedText.attributedSubstring(from: range) |
| 2403 | segment.draw(in: lineRect) |
| 2404 | } |
| 2405 | } else { |
| 2406 | let content = (row.content ?? "") as NSString |
| 2407 | drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in |
| 2408 | let segment = range.length == content.length ? content as String : content.substring(with: range) |
| 2409 | drawText(segment, rect: lineRect, color: theme.text, font: codeFont) |
| 2410 | } |
| 2411 | } |
| 2412 | context.restoreGState() |
| 2413 | } |
| 2414 | |
| 2415 | /// Draws the segment starting at each of the row's line starts on its own visual line. |
| 2416 | private func drawCodeLines( |
| 2417 | length: Int, |
| 2418 | lineStarts: [Int], |
| 2419 | firstLineRect: CGRect, |
| 2420 | draw: (NSRange, CGRect) -> Void |
| 2421 | ) { |
| 2422 | var lineRect = firstLineRect |
| 2423 | let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds |
| 2424 | let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight))) |
| 2425 | let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight))) |
| 2426 | guard first < last else { return } |
| 2427 | lineRect.origin.y += CGFloat(first) * codeWrapLineHeight |
| 2428 | for line in first..<last { |
| 2429 | let lineStart = lineStarts[line] |
| 2430 | let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : length |
| 2431 | draw(NSRange(location: lineStart, length: lineEnd - lineStart), lineRect) |
| 2432 | lineRect.origin.y += codeWrapLineHeight |
| 2433 | } |
| 2434 | } |
| 2435 | |
| 2436 | private func drawSelectionOverlay( |
| 2437 | _ row: ReviewDiffNativeRow, |
| 2438 | rect: CGRect, |
| 2439 | context: CGContext |
| 2440 | ) { |
| 2441 | guard selectedRowIds.contains(row.id) else { |
| 2442 | return |
| 2443 | } |
| 2444 | |
| 2445 | // Keep the diff and syntax colors intact; selection lives in the gutter. |
| 2446 | theme.hunkText.withAlphaComponent(0.95).setFill() |
| 2447 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) |
| 2448 | } |
| 2449 | |
| 2450 | private func drawWordDiffRanges( |
| 2451 | _ row: ReviewDiffNativeRow, |
| 2452 | lineStarts: [Int], |
| 2453 | firstLineRect: CGRect, |
| 2454 | context: CGContext, |
| 2455 | horizontalOffset: CGFloat |
| 2456 | ) { |
| 2457 | guard let ranges = row.wordDiffRanges, !ranges.isEmpty else { |
| 2458 | return |
| 2459 | } |
| 2460 | |
| 2461 | let change = row.change ?? "context" |
| 2462 | guard change == "add" || change == "delete" else { |
| 2463 | return |
| 2464 | } |
| 2465 | |
| 2466 | let fillColor: UIColor |
| 2467 | if change == "add" { |
| 2468 | fillColor = theme.addBar.withAlphaComponent(0.28) |
| 2469 | } else { |
| 2470 | fillColor = theme.deleteBar.withAlphaComponent(0.28) |
| 2471 | } |
| 2472 | let highlightHeight = max(4, min(firstLineRect.height - 4, codeFont.lineHeight)) |
| 2473 | let highlightY = firstLineRect.midY - highlightHeight / 2 |
| 2474 | |
| 2475 | fillColor.setFill() |
| 2476 | for range in ranges { |
| 2477 | guard range.end > range.start else { |
| 2478 | continue |
| 2479 | } |
| 2480 | |
| 2481 | // A wrapped row splits the highlight at each visual line boundary. |
| 2482 | for (line, lineStart) in lineStarts.enumerated() { |
| 2483 | let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : Int.max |
| 2484 | let start = max(range.start, lineStart) |
| 2485 | let end = min(range.end, lineEnd) |
| 2486 | guard end > start else { |
| 2487 | continue |
| 2488 | } |
| 2489 | let highlightRect = CGRect( |
| 2490 | x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth, |
| 2491 | y: highlightY + CGFloat(line) * codeWrapLineHeight, |
| 2492 | width: max(2, CGFloat(end - start) * codeCharacterWidth), |
| 2493 | height: highlightHeight |
| 2494 | ) |
| 2495 | UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() |
| 2496 | } |
| 2497 | } |
| 2498 | } |
| 2499 | |
| 2500 | private func rowBackground(for change: String) -> UIColor { |
| 2501 | if change == "add" { |
| 2502 | return theme.addBackground |
| 2503 | } |
| 2504 | if change == "delete" { |
| 2505 | return theme.deleteBackground |
| 2506 | } |
| 2507 | return theme.background |
| 2508 | } |
| 2509 | |
| 2510 | private func lineNumberColor(for change: String) -> UIColor { |
| 2511 | if change == "add" { |
| 2512 | return theme.addText |
| 2513 | } |
| 2514 | if change == "delete" { |
| 2515 | return theme.deleteText |
| 2516 | } |
| 2517 | return theme.mutedText |
| 2518 | } |
| 2519 | |
| 2520 | private func drawDeleteStripes(rect: CGRect, context: CGContext) { |
| 2521 | theme.deleteBar.setFill() |
| 2522 | var y = rect.minY |
| 2523 | while y < rect.maxY { |
| 2524 | context.fill(CGRect(x: rect.minX, y: y, width: rect.width, height: 1)) |
| 2525 | y += 2 |
| 2526 | } |
| 2527 | } |
| 2528 | |
| 2529 | private func drawText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { |
| 2530 | let attributes: [NSAttributedString.Key: Any] = [ |
| 2531 | .font: font, |
| 2532 | .foregroundColor: color, |
| 2533 | .ligature: 0, |
| 2534 | ] |
| 2535 | (text as NSString).draw(in: rect, withAttributes: attributes) |
| 2536 | } |
| 2537 | |
| 2538 | private func drawMultilineText( |
| 2539 | _ text: String, |
| 2540 | rect: CGRect, |
| 2541 | color: UIColor, |
| 2542 | font: UIFont, |
| 2543 | maximumLineCount: Int |
| 2544 | ) { |
| 2545 | let paragraphStyle = NSMutableParagraphStyle() |
| 2546 | paragraphStyle.lineBreakMode = .byTruncatingTail |
| 2547 | paragraphStyle.lineSpacing = 2 |
| 2548 | |
| 2549 | let attributes: [NSAttributedString.Key: Any] = [ |
| 2550 | .font: font, |
| 2551 | .foregroundColor: color, |
| 2552 | .paragraphStyle: paragraphStyle, |
| 2553 | .ligature: 0, |
| 2554 | ] |
| 2555 | let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing) |
| 2556 | (text as NSString).draw( |
| 2557 | in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)), |
| 2558 | withAttributes: attributes |
| 2559 | ) |
| 2560 | } |
| 2561 | |
| 2562 | private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat { |
| 2563 | rect.midY - font.lineHeight / 2 |
| 2564 | } |
| 2565 | |
| 2566 | private func drawSingleLineText( |
| 2567 | _ text: String, |
| 2568 | rect: CGRect, |
| 2569 | color: UIColor, |
| 2570 | font: UIFont, |
| 2571 | horizontalOffset: CGFloat = 0 |
| 2572 | ) { |
| 2573 | guard let context = UIGraphicsGetCurrentContext() else { |
| 2574 | return |
| 2575 | } |
| 2576 | |
| 2577 | let attributes: [NSAttributedString.Key: Any] = [ |
| 2578 | .font: font, |
| 2579 | .foregroundColor: color, |
| 2580 | .ligature: 0, |
| 2581 | ] |
| 2582 | context.saveGState() |
| 2583 | context.clip(to: rect) |
| 2584 | let textY = rect.midY - font.lineHeight / 2 |
| 2585 | (text as NSString).draw( |
| 2586 | at: CGPoint(x: rect.minX - horizontalOffset, y: textY), |
| 2587 | withAttributes: attributes |
| 2588 | ) |
| 2589 | context.restoreGState() |
| 2590 | } |
| 2591 | |
| 2592 | private func drawFileHeaderPathScrollFade( |
| 2593 | _ row: ReviewDiffNativeRow, |
| 2594 | pathRect: CGRect, |
| 2595 | horizontalOffset: CGFloat, |
| 2596 | context: CGContext |
| 2597 | ) { |
| 2598 | let maxOffset = maxHeaderPathOffset(for: row) |
| 2599 | guard maxOffset > 0, pathRect.width > 0 else { |
| 2600 | return |
| 2601 | } |
| 2602 | |
| 2603 | let fadeWidth = min(CGFloat(28), pathRect.width / 3) |
| 2604 | if horizontalOffset > 0.5 { |
| 2605 | drawHorizontalFade( |
| 2606 | rect: CGRect(x: pathRect.minX, y: pathRect.minY, width: fadeWidth, height: pathRect.height), |
| 2607 | color: theme.headerBackground, |
| 2608 | fadesToRight: false, |
| 2609 | context: context |
| 2610 | ) |
| 2611 | } |
| 2612 | |
| 2613 | if horizontalOffset < maxOffset - 0.5 { |
| 2614 | drawHorizontalFade( |
| 2615 | rect: CGRect(x: pathRect.maxX - fadeWidth, y: pathRect.minY, width: fadeWidth, height: pathRect.height), |
| 2616 | color: theme.headerBackground, |
| 2617 | fadesToRight: true, |
| 2618 | context: context |
| 2619 | ) |
| 2620 | } |
| 2621 | } |
| 2622 | |
| 2623 | private func drawHorizontalFade( |
| 2624 | rect: CGRect, |
| 2625 | color: UIColor, |
| 2626 | fadesToRight: Bool, |
| 2627 | context: CGContext |
| 2628 | ) { |
| 2629 | guard rect.width > 0, |
| 2630 | let gradient = CGGradient( |
| 2631 | colorsSpace: CGColorSpaceCreateDeviceRGB(), |
| 2632 | colors: [ |
| 2633 | color.withAlphaComponent(fadesToRight ? 0 : 1).cgColor, |
| 2634 | color.withAlphaComponent(fadesToRight ? 1 : 0).cgColor, |
| 2635 | ] as CFArray, |
| 2636 | locations: [0, 1] |
| 2637 | ) else { |
| 2638 | return |
| 2639 | } |
| 2640 | |
| 2641 | context.saveGState() |
| 2642 | context.clip(to: rect) |
| 2643 | context.drawLinearGradient( |
| 2644 | gradient, |
| 2645 | start: CGPoint(x: rect.minX, y: rect.midY), |
| 2646 | end: CGPoint(x: rect.maxX, y: rect.midY), |
| 2647 | options: [] |
| 2648 | ) |
| 2649 | context.restoreGState() |
| 2650 | } |
| 2651 | |
| 2652 | private func textWidth(_ text: String, font: UIFont) -> CGFloat { |
| 2653 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0] |
| 2654 | return ceil((text as NSString).size(withAttributes: attributes).width) |
| 2655 | } |
| 2656 | |
| 2657 | private func monospaceCharacterWidth(font: UIFont) -> CGFloat { |
| 2658 | let sampleLength = 64 |
| 2659 | let sample = String(repeating: "M", count: sampleLength) |
| 2660 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0] |
| 2661 | return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) |
| 2662 | } |
| 2663 | |
| 2664 | private func tokenAttributedString( |
| 2665 | rowId: String, |
| 2666 | tokens: [ReviewDiffNativeToken], |
| 2667 | fallbackColor: UIColor, |
| 2668 | font: UIFont |
| 2669 | ) -> NSAttributedString { |
| 2670 | if let cached = tokenAttributedStringsByRowId[rowId] { |
| 2671 | return cached |
| 2672 | } |
| 2673 | |
| 2674 | let attributedString = NSMutableAttributedString(string: "") |
| 2675 | for token in tokens where !token.content.isEmpty { |
| 2676 | attributedString.append( |
| 2677 | NSAttributedString( |
| 2678 | string: token.content, |
| 2679 | attributes: [ |
| 2680 | .font: font, |
| 2681 | .foregroundColor: tokenColor(for: token.color, fallbackColor: fallbackColor), |
| 2682 | .ligature: 0, |
| 2683 | ] |
| 2684 | ) |
| 2685 | ) |
| 2686 | } |
| 2687 | |
| 2688 | tokenAttributedStringsByRowId[rowId] = attributedString |
| 2689 | return attributedString |
| 2690 | } |
| 2691 | |
| 2692 | private func tokenColor(for hex: String?, fallbackColor: UIColor) -> UIColor { |
| 2693 | guard let hex, !hex.isEmpty else { |
| 2694 | return fallbackColor |
| 2695 | } |
| 2696 | |
| 2697 | if let color = tokenColorsByHex[hex] { |
| 2698 | return color |
| 2699 | } |
| 2700 | |
| 2701 | guard let color = UIColor(reviewDiffHex: hex) else { |
| 2702 | return fallbackColor |
| 2703 | } |
| 2704 | |
| 2705 | tokenColorsByHex[hex] = color |
| 2706 | return color |
| 2707 | } |
| 2708 | |
| 2709 | private func drawRightAlignedText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { |
| 2710 | let paragraphStyle = NSMutableParagraphStyle() |
| 2711 | paragraphStyle.alignment = .right |
| 2712 | let attributes: [NSAttributedString.Key: Any] = [ |
| 2713 | .font: font, |
| 2714 | .foregroundColor: color, |
| 2715 | .paragraphStyle: paragraphStyle, |
| 2716 | ] |
| 2717 | (text as NSString).draw(in: rect, withAttributes: attributes) |
| 2718 | } |
| 2719 | } |
| 2720 | |
| 2721 | private extension UIColor { |
| 2722 | convenience init?(reviewDiffHex hex: String?) { |
| 2723 | guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { |
| 2724 | return nil |
| 2725 | } |
| 2726 | |
| 2727 | if value.hasPrefix("#") { |
| 2728 | value.removeFirst() |
| 2729 | } |
| 2730 | |
| 2731 | guard value.count == 6 || value.count == 8 else { |
| 2732 | return nil |
| 2733 | } |
| 2734 | |
| 2735 | var rawValue: UInt64 = 0 |
| 2736 | guard Scanner(string: value).scanHexInt64(&rawValue) else { |
| 2737 | return nil |
| 2738 | } |
| 2739 | |
| 2740 | let red: CGFloat |
| 2741 | let green: CGFloat |
| 2742 | let blue: CGFloat |
| 2743 | let alpha: CGFloat |
| 2744 | |
| 2745 | if value.count == 8 { |
| 2746 | red = CGFloat((rawValue >> 24) & 0xff) / 255 |
| 2747 | green = CGFloat((rawValue >> 16) & 0xff) / 255 |
| 2748 | blue = CGFloat((rawValue >> 8) & 0xff) / 255 |
| 2749 | alpha = CGFloat(rawValue & 0xff) / 255 |
| 2750 | } else { |
| 2751 | red = CGFloat((rawValue >> 16) & 0xff) / 255 |
| 2752 | green = CGFloat((rawValue >> 8) & 0xff) / 255 |
| 2753 | blue = CGFloat(rawValue & 0xff) / 255 |
| 2754 | alpha = 1 |
| 2755 | } |
| 2756 | |
| 2757 | self.init(red: red, green: green, blue: blue, alpha: alpha) |
| 2758 | } |
| 2759 | } |