Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import UIKit

/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights.
/// Pass `unboundedWidth` to lay a row out on one line for horizontal scrolling.
final class ReviewDiffCodeLayout: NSObject {
static let unboundedWidth = CGFloat.greatestFiniteMagnitude

// Measurement reuses one engine; only recently drawn rows retain a full TextKit layout.
private static var measurer: ReviewDiffTextLayout {
let key = "T3ReviewDiff.textMeasurer"
Expand All @@ -20,6 +23,8 @@ final class ReviewDiffCodeLayout: NSObject {
let lineHeight: CGFloat
let firstLineHeight: CGFloat
let extraHeight: CGFloat
/// Widest visual line, which sizes horizontal scrolling when the row is not wrapped.
let usedWidth: CGFloat
private let font: UIFont
private let width: CGFloat
private let characterWidth: CGFloat
Expand All @@ -32,10 +37,12 @@ final class ReviewDiffCodeLayout: NSObject {
self.characterWidth = characterWidth
lineHeight = ceil(font.lineHeight)
if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) {
let columns = max(1, Int(width / characterWidth))
starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns))
let length = max(1, text.utf8.count)
let columns = max(1, Int(min(width / characterWidth, CGFloat(length))))
starts = Array(stride(from: 0, to: length, by: columns))
firstLineHeight = font.lineHeight
extraHeight = CGFloat(starts.count - 1) * lineHeight
usedWidth = CGFloat(min(text.utf8.count, columns)) * characterWidth
usesNativeLayout = false
} else {
let layout = Self.measurer
Expand All @@ -46,7 +53,9 @@ final class ReviewDiffCodeLayout: NSObject {
starts = [0]
firstLineHeight = manager.numberOfGlyphs > 0
? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight
extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight)
let used = manager.usedRect(for: container)
extraHeight = max(0, used.height - firstLineHeight)
usedWidth = ceil(used.width)
}
}

Expand Down
50 changes: 28 additions & 22 deletions apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,14 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
ceil(codeFont.lineHeight)
}

/// Wrapped rows fill the code viewport; unwrapped rows keep one line and scroll horizontally.
private func codeLayoutWidthLimit(font: UIFont) -> CGFloat? {
guard style.wordWrap else { return ReviewDiffCodeLayout.unboundedWidth }
let width = viewportWidth - codeStartX - style.codePadding
let characterWidth = monospaceCharacterWidth(font: font)
return characterWidth > 0 && width >= characterWidth ? width : nil
}

func frameForRow(at index: Int) -> CGRect? {
guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {
return nil
Expand All @@ -1141,9 +1149,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
completion: @escaping () -> Void
) {
let font = codeFont
let width = viewportWidth - codeStartX - style.codePadding
let characterWidth = monospaceCharacterWidth(font: font)
guard style.wordWrap, width >= characterWidth, characterWidth > 0 else {
guard let width = codeLayoutWidthLimit(font: font) else {
completion()
return
}
Expand All @@ -1160,7 +1167,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
}
DispatchQueue.main.async { [weak self] in
guard let self, isCurrent() else { return }
if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width {
if self.codeFont != font || self.codeLayoutWidthLimit(font: font) != width {
self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion)
return
}
Expand All @@ -1176,18 +1183,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
var nextOffsets: [CGFloat] = []
var nextFileHeaderRowIndices: [Int] = []
nextOffsets.reserveCapacity(rows.count)
var maxColumnCountsByFileId: [String: Int] = [:]
var maxWidthsByFileId: [String: CGFloat] = [:]
var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:]
var offset: CGFloat = 0
let font = codeFont
let characterWidth = monospaceCharacterWidth(font: font)
let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding
let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth
? Int(wrapAvailableWidth / characterWidth)
: nil
if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font {
let layoutWidth = codeLayoutWidthLimit(font: font)
let wrapColumns = style.wordWrap ? layoutWidth.map { Int($0 / characterWidth) } : nil
if let layoutWidth, codeLayoutWidth != layoutWidth || codeLayoutFont != font {
codeLayoutsByRowId.removeAll()
codeLayoutWidth = wrapAvailableWidth
codeLayoutWidth = layoutWidth
codeLayoutFont = font
}

Expand All @@ -1202,27 +1207,30 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
switch row.kind {
case "line":
// UTF-16 columns match the word diff ranges and the segments drawCodeLines draws.
let columnCount = row.content?.utf16.count ?? 0
maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount)
if wrapColumns != nil, let content = row.content {
var width = CGFloat(row.content?.utf16.count ?? 0) * characterWidth
if let layoutWidth, let content = row.content {
let cached = codeLayoutsByRowId[row.id]
let layout: ReviewDiffCodeLayout
if let cached, cached.text == content {
layout = cached
} else {
layout = ReviewDiffCodeLayout(
text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth
text: content, font: font, width: layoutWidth, characterWidth: characterWidth
)
}
nextCodeLayouts[row.id] = layout
// Tabs and fallback glyphs are wider than one column.
width = layout.usedWidth
if rowHeight > 0 {
rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight
}
}
maxWidthsByFileId[fileId] = max(maxWidthsByFileId[fileId] ?? 0, width)
case "hunk":
maxColumnCountsByFileId[fileId] = max(
maxColumnCountsByFileId[fileId] ?? 0,
row.text?.utf16.count ?? 0
// Hunk context can hold tabs and draws in its own font, so measure what drawHunkRow draws.
maxWidthsByFileId[fileId] = max(
maxWidthsByFileId[fileId] ?? 0,
textWidth(row.text ?? "", font: hunkFont)
)
default:
break
Expand All @@ -1233,10 +1241,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
codeCharacterWidth = characterWidth
codeWrapColumns = wrapColumns
codeLayoutsByRowId = nextCodeLayouts
contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
return max(0, min(style.contentWidth, measuredWidth))
}
// Measured widths are not capped, so the end of every line stays reachable by scrolling.
contentWidthsByFileId = maxWidthsByFileId.mapValues { ceil($0) + style.codePadding * 2 }
rowOffsets = nextOffsets
fileHeaderRowIndices = nextFileHeaderRowIndices
contentHeight = offset
Expand Down Expand Up @@ -2375,7 +2381,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
version: codeDecorationVersion
)
layout.draw(
at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)),
at: CGPoint(x: codeStartX - horizontalOffset, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)),
clip: context.boundingBoxOfClipPath
)
context.restoreGState()
Expand Down
35 changes: 34 additions & 1 deletion apps/mobile/modules/t3-review-diff/tests/ios/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,39 @@ for fixture in fixtures {
cases += 1
}
}
// Unwrapped rows stay on one line, and their measured width covers every drawn glyph.
for text in ["\t\t\"extends\": \"@riptech/tsconfig/tsconfig.test.json\",", "漢字\tvalue", "مرحبا\tvalue"] {
let layout = ReviewDiffCodeLayout(
text: text, font: font, width: ReviewDiffCodeLayout.unboundedWidth, characterWidth: characterWidth
)
check(layout.usesNativeLayout && layout.extraHeight == 0, "Unwrapped text must stay on one line: \(text)")
check(layout.usedWidth > CGFloat(text.utf16.count - 1) * characterWidth, "Tabs must widen the row: \(text)")
layout.decorate(text: NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.black]), highlights: [], color: .clear, version: 0)
let format = UIGraphicsImageRendererFormat()
format.scale = 1
format.opaque = false
format.preferredRange = .standard
let size = CGSize(width: layout.usedWidth + 40, height: ceil(layout.firstLineHeight))
let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in
layout.draw(at: .zero, clip: CGRect(origin: .zero, size: size))
}
let bitmap = image.cgImage!
let bytes = CFDataGetBytePtr(bitmap.dataProvider!.data!)!
var inkMaxX = -1
for y in 0..<bitmap.height {
for x in 0..<bitmap.width where bytes[y * bitmap.bytesPerRow + x * 4 + 3] != 0 {
inkMaxX = max(inkMaxX, x)
}
}
check(inkMaxX <= Int(layout.usedWidth) + 1, "Ink beyond measured width: \(text) x=\(inkMaxX)")
check(CGFloat(inkMaxX) >= layout.usedWidth - characterWidth * 2, "Row end was not drawn: \(text) x=\(inkMaxX)")
cases += 1
}
let unwrappedAscii = ReviewDiffCodeLayout(
text: "const value = 123;", font: font, width: ReviewDiffCodeLayout.unboundedWidth, characterWidth: characterWidth
)
check(unwrappedAscii.starts == [0] && unwrappedAscii.usedWidth == 18 * characterWidth)

let ascii = String(repeating: "const value = 123; ", count: 100)
let layout = ReviewDiffCodeLayout(text: ascii, font: font, width: 280, characterWidth: characterWidth)
let string = ascii as NSString
Expand All @@ -57,4 +90,4 @@ for (index, start) in layout.starts.enumerated() {
reconstructed += segment
}
check(reconstructed == ascii)
print("Passed \(cases) Unicode/width cases, color-update geometry, and ASCII coverage")
print("Passed \(cases) Unicode/width and unwrapped cases, color-update geometry, and ASCII coverage")
Loading