authorgravatar for jakeleventhal@me.comJake Leventhal <jakeleventhal@me.com> 2026-09-20 18:04:01-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 15:04:01-07:00
logf6cc6bc7e9cbe1feeb7811ea92d519ad77c76596
treecf3a8db3a32c51721419c8e6e6b93b1a70c3c664
parent45e06f48a03354b600782259010ae6222241aff0
signature Signed by PGP key B5690EEEBB952194

fix(mobile): respect word wrap in diffs (#12590)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

15 files changed, 1032 insertions(+), 119 deletions(-)

apps/mobile/modules/t3-review-diff/android/build.gradle+12
...@@ -8,6 +8,10 @@ android {...@@ -8,6 +8,10 @@ android {
8 namespace 'expo.modules.t3reviewdiff'8 namespace 'expo.modules.t3reviewdiff'
9 compileSdk rootProject.ext.compileSdkVersion9 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
11 defaultConfig {15 defaultConfig {
12 minSdkVersion rootProject.ext.minSdkVersion16 minSdkVersion rootProject.ext.minSdkVersion
13 targetSdkVersion rootProject.ext.targetSdkVersion17 targetSdkVersion rootProject.ext.targetSdkVersion
...@@ -16,4 +20,12 @@ android {...@@ -16,4 +20,12 @@ android {
1620
17dependencies {21dependencies {
18 implementation project(':expo-modules-core')22 implementation project(':expo-modules-core')
23 testImplementation 'junit:junit:4.13.2'
24 testImplementation 'org.robolectric:robolectric:4.16.1'
25}
26
27tasks.withType(Test).configureEach {
28 javaLauncher = javaToolchains.launcherFor {
29 languageVersion = JavaLanguageVersion.of(21)
30 }
19}31}
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt+99-9
...@@ -9,6 +9,7 @@ import android.graphics.Path...@@ -9,6 +9,7 @@ import android.graphics.Path
9import android.graphics.RectF9import android.graphics.RectF
10import android.graphics.Shader10import android.graphics.Shader
11import android.graphics.Typeface11import android.graphics.Typeface
12import android.text.TextPaint
12import kotlin.math.max13import kotlin.math.max
13import kotlin.math.min14import kotlin.math.min
1415
...@@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) {...@@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) {
177 textPaint.isUnderlineText = fontStyle and 4 != 0178 textPaint.isUnderlineText = fontStyle and 4 != 0
178 }179 }
179180
181 var codeLayouts = CodeLayoutCache()
182
183 /** Capture paint on the UI thread; the decode worker owns the new cache until publication. */
184 fun prepareRows(
185 tokens: Map<String, List<DiffToken>>,
186 style: DiffStyle,
187 width: Int
188 ): (List<DiffRow>) -> CodeLayoutCache {
189 configureCodePaint(theme.text, 0, style)
190 val paint = TextPaint(textPaint)
191 val colors = theme
192 val cache = codeLayouts.copyForPreparation()
193 val availableWidth = (
194 width - style.changeBarWidthPx - style.gutterWidthPx -
195 style.codePaddingPx * 2f
196 ).toInt()
197 return { rows ->
198 cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) }
199 }
200 }
201
202 fun codeWrapLayout(
203 rows: List<DiffRow>,
204 tokens: Map<String, List<DiffToken>>,
205 style: DiffStyle,
206 width: Int
207 ): CodeWrapLayout {
208 configureCodePaint(theme.text, 0, style)
209 val availableWidth = width - style.changeBarWidthPx - style.gutterWidthPx -
210 style.codePaddingPx * 2f
211 return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt())
212 }
213
180 fun lineNumberColor(change: String): Int = when (change) {214 fun lineNumberColor(change: String): Int = when (change) {
181 "add" -> theme.addText215 "add" -> theme.addText
182 "delete" -> theme.deleteText216 "delete" -> theme.deleteText
...@@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) {...@@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) {
198 }232 }
199 }233 }
200234
235 /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */
236 @Suppress("LongParameterList")
201 fun drawWordDiffRanges(237 fun drawWordDiffRanges(
202 canvas: Canvas,238 canvas: Canvas,
203 row: DiffRow,239 row: DiffRow,
204 codeX: Float,240 codeX: Float,
205 top: Int,241 top: Int,
206 bottom: Int242 bottom: Int,
243 lines: CodeLines
207 ) {244 ) {
245 if (lines.nativeLayout != null) return
208 if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return246 if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return
209 val color = if (row.change == "add") theme.addBar else theme.deleteBar247 val color = if (row.change == "add") theme.addBar else theme.deleteBar
210 backgroundPaint.color = withAlpha(color, 71)248 backgroundPaint.color = withAlpha(color, 71)
...@@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) {...@@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) {
213 val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight))251 val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight))
214 val highlightTop = (top + bottom - highlightHeight) / 2f252 val highlightTop = (top + bottom - highlightHeight) / 2f
215 row.wordDiffRanges.forEach { range ->253 row.wordDiffRanges.forEach { range ->
216 val left = codeX + range.start * characterWidth254 // A wrapped row splits the highlight at each visual line boundary.
217 val right = max(left + 2f * density, codeX + range.end * characterWidth)255 lines.starts.forEachIndexed { line, lineStart ->
218 canvas.drawRoundRect(256 val start = max(range.start, lineStart)
219 RectF(left, highlightTop, right, highlightTop + highlightHeight),257 val end = min(range.end, lines.end(line, Int.MAX_VALUE))
220 3f * density,258 if (end <= start) return@forEachIndexed
221 3f * density,259 val left = codeX + (start - lineStart) * characterWidth
222 backgroundPaint,260 val right = max(left + 2f * density, left + (end - start) * characterWidth)
223 )261 val lineTop = highlightTop + line * lines.height
262 canvas.drawRoundRect(
263 RectF(left, lineTop, right, lineTop + highlightHeight),
264 3f * density,
265 3f * density,
266 backgroundPaint,
267 )
268 }
269 }
270 }
271
272 /** Draws a code row's text, or its syntax [tokens] when present, one visual line per start. */
273 @Suppress("LongParameterList")
274 fun drawCode(
275 canvas: Canvas,
276 content: String,
277 tokens: List<DiffToken>?,
278 codeX: Float,
279 baseline: Float,
280 style: DiffStyle,
281 lines: CodeLines
282 ) {
283 val nativeLayout = lines.nativeLayout
284 if (nativeLayout != null) {
285 canvas.save()
286 canvas.translate(codeX, baseline - nativeLayout.getLineBaseline(0))
287 nativeLayout.draw(canvas)
288 canvas.restore()
289 return
290 }
291 val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens
292 var line = 0
293 var x = codeX
294 var column = 0
295 runs.forEach { run ->
296 configureCodePaint(run.color ?: theme.text, run.fontStyle, style)
297 var start = 0
298 while (start < run.content.length) {
299 while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) {
300 line += 1
301 x = codeX
302 }
303 val end = min(run.content.length, lines.end(line, Int.MAX_VALUE) - column)
304 val lineBaseline = baseline + line * lines.height
305 if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top &&
306 lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom
307 ) {
308 canvas.drawText(run.content, start, end, x, lineBaseline, textPaint)
309 x += textPaint.measureText(run.content, start, end)
310 }
311 start = end
312 }
313 column += run.content.length
224 }314 }
225 }315 }
226316
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt created+169
...@@ -0,0 +1,169 @@
1package expo.modules.t3reviewdiff
2
3import android.graphics.Color
4import android.graphics.Paint
5import android.graphics.Typeface
6import android.text.Layout
7import android.text.SpannableString
8import android.text.Spanned
9import android.text.StaticLayout
10import android.text.TextPaint
11import android.text.style.BackgroundColorSpan
12import android.text.style.ForegroundColorSpan
13import android.text.style.StyleSpan
14import android.text.style.UnderlineSpan
15import kotlin.math.ceil
16import kotlin.math.max
17
18/** Text layout is independent of comment heights and vertical row offsets. */
19internal class CodeLines(
20 val starts: IntArray,
21 val height: Int,
22 val nativeLayout: StaticLayout? = null
23) {
24 fun end(line: Int, length: Int): Int = if (line + 1 < starts.size) starts[line + 1] else length
25
26 fun firstHeight(base: Int): Int = max(base, nativeLayout?.getLineBottom(0) ?: 0)
27
28 fun baseline(top: Int, bottom: Int, paint: Paint): Float = nativeLayout?.let {
29 top + (bottom - top - it.getLineBottom(0)) / 2f + it.getLineBaseline(0)
30 } ?: ((top + bottom - paint.fontMetrics.ascent - paint.fontMetrics.descent) / 2f)
31
32 val extraHeight: Int
33 get() = nativeLayout?.let { it.height - it.getLineBottom(0) } ?: ((starts.size - 1) * height)
34}
35
36internal class CodeWrapLayout(
37 val enabled: Boolean,
38 private val linesByRowId: Map<String, CodeLines>
39) {
40 fun lines(rowId: String): CodeLines = linesByRowId[rowId] ?: SINGLE_LINE
41 fun extraHeight(rowId: String): Int = lines(rowId).extraHeight
42 fun rowHeight(rowId: String, base: Int): Int = lines(rowId).let {
43 it.firstHeight(base) +
44 it.extraHeight
45 }
46
47 companion object {
48 private val SINGLE_LINE = CodeLines(intArrayOf(0), 0)
49 val NONE = CodeWrapLayout(false, emptyMap())
50 }
51}
52
53/** ASCII is fixed-pitch; other text needs the same shaping for measurement and drawing. */
54internal fun createCodeLines(text: CharSequence, paint: TextPaint, width: Int): CodeLines {
55 val characterWidth = paint.measureText("M")
56 val lineHeight = ceil(paint.fontMetrics.run { descent - ascent }).toInt()
57 if (text.all { it in ' '..'~' }) {
58 val columns = max(1, (width / characterWidth).toInt())
59 return CodeLines(
60 IntArray(max(1, (text.length + columns - 1) / columns)) {
61 it * columns
62 },
63 lineHeight
64 )
65 }
66 val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, max(1, width))
67 .setAlignment(Layout.Alignment.ALIGN_NORMAL)
68 .setIncludePad(false)
69 .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE)
70 .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE)
71 .build()
72 return CodeLines(IntArray(layout.lineCount) { layout.getLineStart(it) }, lineHeight, layout)
73}
74
75internal class CodeLayoutCache {
76 private data class Entry(val row: DiffRow, val tokens: List<DiffToken>?, val lines: CodeLines)
77 private var entries = emptyMap<String, Entry>()
78 private var previousStyle: DiffStyle? = null
79 private var previousTheme: DiffTheme? = null
80 private var previousWidth = 0
81
82 /** Entries are immutable; a worker can reuse them without changing the displayed cache. */
83 fun copyForPreparation(): CodeLayoutCache = CodeLayoutCache().also {
84 it.entries = entries
85 it.previousStyle = previousStyle
86 it.previousTheme = previousTheme
87 it.previousWidth = previousWidth
88 }
89
90 @Suppress("LongParameterList")
91 fun layout(
92 rows: List<DiffRow>,
93 tokens: Map<String, List<DiffToken>>,
94 paint: Paint,
95 style: DiffStyle,
96 theme: DiffTheme,
97 width: Int
98 ): CodeWrapLayout {
99 if (!style.wordWrap || width < paint.measureText("M")) {
100 entries = emptyMap()
101 return CodeWrapLayout.NONE
102 }
103 if (previousStyle != style || previousTheme != theme || previousWidth != width) {
104 entries = emptyMap()
105 previousStyle = style
106 previousTheme = theme
107 previousWidth = width
108 }
109 val next = HashMap<String, Entry>()
110 val layouts = HashMap<String, CodeLines>()
111 for (row in rows) {
112 if (row.kind != "line") continue
113 val rowTokens = tokens[row.id]
114 val cached = entries[row.id]
115 val entry = if (cached?.row == row && cached.tokens == rowTokens) {
116 cached
117 } else {
118 val text = styledCode(row, rowTokens, theme)
119 Entry(row, rowTokens, createCodeLines(text, TextPaint(paint), width))
120 }
121 next[row.id] = entry
122 layouts[row.id] = entry.lines
123 }
124 entries = next
125 return CodeWrapLayout(true, layouts)
126 }
127
128 private fun styledCode(row: DiffRow, tokens: List<DiffToken>?, theme: DiffTheme): CharSequence {
129 // The ASCII path uses the existing token drawing and rounded highlight rectangles.
130 if (row.content.all { it in ' '..'~' }) return row.content
131 val text = SpannableString(row.content)
132 var offset = 0
133 for (token in tokens.orEmpty()) {
134 val end = (offset + token.content.length).coerceAtMost(text.length)
135 if (end > offset) {
136 token.color?.let {
137 text.setSpan(ForegroundColorSpan(it), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
138 }
139 val fontStyle = (if (token.fontStyle and 2 != 0) Typeface.BOLD else 0) or
140 (if (token.fontStyle and 1 != 0) Typeface.ITALIC else 0)
141 if (fontStyle !=
142 0
143 ) {
144 text.setSpan(StyleSpan(fontStyle), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
145 }
146 if (token.fontStyle and 4 !=
147 0
148 ) {
149 text.setSpan(UnderlineSpan(), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
150 }
151 }
152 offset = end
153 }
154 if (row.change == "add" || row.change == "delete") {
155 val bar = if (row.change == "add") theme.addBar else theme.deleteBar
156 val color = Color.argb(71, Color.red(bar), Color.green(bar), Color.blue(bar))
157 for (range in row.wordDiffRanges) {
158 val start = range.start.coerceIn(0, text.length)
159 val end = range.end.coerceIn(start, text.length)
160 if (end >
161 start
162 ) {
163 text.setSpan(BackgroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
164 }
165 }
166 }
167 return text
168 }
169}
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+36-17
...@@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont...@@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont
143 fun setRowsJson(value: String) {143 fun setRowsJson(value: String) {
144 rowsDecodeGeneration += 1144 rowsDecodeGeneration += 1
145 val generation = rowsDecodeGeneration145 val generation = rowsDecodeGeneration
146 val prepareLayout = canvasView.prepareRows()
146 payloadDecodeExecutor.execute {147 payloadDecodeExecutor.execute {
147 val decodedRows = parseRows(value)148 val decodedRows = parseRows(value)
149 val codeLayouts = prepareLayout(decodedRows)
148 post {150 post {
149 if (generation != rowsDecodeGeneration) return@post151 if (generation != rowsDecodeGeneration) return@post
152 canvasView.useCodeLayouts(codeLayouts)
150 rows = decodedRows153 rows = decodedRows
151 lastVisibleFileId = null154 lastVisibleFileId = null
152 rebuildVisibleRows()155 rebuildVisibleRows()
...@@ -460,7 +463,7 @@ internal data class DiffWordDiffRange(...@@ -460,7 +463,7 @@ internal data class DiffWordDiffRange(
460 val end: Int463 val end: Int
461)464)
462465
463private data class DiffToken(466internal data class DiffToken(
464 val content: String,467 val content: String,
465 val color: Int?,468 val color: Int?,
466 val fontStyle: Int469 val fontStyle: Int
...@@ -543,6 +546,7 @@ internal data class DiffTheme(...@@ -543,6 +546,7 @@ internal data class DiffTheme(
543}546}
544547
545internal data class DiffStyle(548internal data class DiffStyle(
549 val wordWrap: Boolean,
546 val rowHeightPx: Float,550 val rowHeightPx: Float,
547 val gutterWidthPx: Float,551 val gutterWidthPx: Float,
548 val codePaddingPx: Float,552 val codePaddingPx: Float,
...@@ -564,6 +568,7 @@ internal data class DiffStyle(...@@ -564,6 +568,7 @@ internal data class DiffStyle(
564) {568) {
565 companion object {569 companion object {
566 fun defaults(density: Float): DiffStyle = DiffStyle(570 fun defaults(density: Float): DiffStyle = DiffStyle(
571 wordWrap = false,
567 rowHeightPx = 20f * density,572 rowHeightPx = 20f * density,
568 gutterWidthPx = 72f * density,573 gutterWidthPx = 72f * density,
569 codePaddingPx = 10f * density,574 codePaddingPx = 10f * density,
...@@ -587,6 +592,7 @@ internal data class DiffStyle(...@@ -587,6 +592,7 @@ internal data class DiffStyle(
587 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {592 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {
588 val json = JSONObject(value)593 val json = JSONObject(value)
589 DiffStyle(594 DiffStyle(
595 wordWrap = json.optBoolean("wordWrap", fallback.wordWrap),
590 rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density),596 rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density),
591 gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density),597 gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density),
592 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),598 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),
...@@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) {
684 },690 },
685 )691 )
686 private var rowOffsets = intArrayOf(0)692 private var rowOffsets = intArrayOf(0)
693
694 private var codeWrap = CodeWrapLayout.NONE
687 private var verticalOffset = 0695 private var verticalOffset = 0
688 private var horizontalOffset = 0696 private var horizontalOffset = 0
689 private val headerPathOffsetsByFileId = mutableMapOf<String, Int>()697 private val headerPathOffsetsByFileId = mutableMapOf<String, Int>()
...@@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) {
699 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()707 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()
700 set(value) {708 set(value) {
701 field = value709 field = value
710 if (style.wordWrap) rebuildOffsets()
702 invalidate()711 invalidate()
703 }712 }
704 var viewedFileIds: Set<String> = emptySet()713 var viewedFileIds: Set<String> = emptySet()
...@@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) {
725 set(value) {734 set(value) {
726 field = value735 field = value
727 drawing.theme = value736 drawing.theme = value
737 if (style.wordWrap) rebuildOffsets()
728 invalidate()738 invalidate()
729 }739 }
730 var style: DiffStyle = DiffStyle.defaults(density)740 var style: DiffStyle = DiffStyle.defaults(density)
...@@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) {
742 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null752 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null
743 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null753 var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null
744754
755 fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width)
756 fun useCodeLayouts(layouts: CodeLayoutCache) {
757 drawing.codeLayouts = layouts
758 }
759
745 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {760 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
746 setMeasuredDimension(761 setMeasuredDimension(
747 MeasureSpec.getSize(widthMeasureSpec),762 MeasureSpec.getSize(widthMeasureSpec),
...@@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) {
751766
752 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {767 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
753 super.onSizeChanged(width, height, oldWidth, oldHeight)768 super.onSizeChanged(width, height, oldWidth, oldHeight)
769 // Wrapped rows take their height from the width, so a new width is a new layout.
770 if (style.wordWrap && width != oldWidth) layoutRows()
754 setVerticalOffset(verticalOffset)771 setVerticalOffset(verticalOffset)
755 setHorizontalOffset(horizontalOffset)772 setHorizontalOffset(horizontalOffset)
756 clampHeaderPathOffsets()773 clampHeaderPathOffsets()
...@@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) {
818835
819 fun horizontalOffset(): Int = horizontalOffset836 fun horizontalOffset(): Int = horizontalOffset
820837
821 fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width)838 fun maxHorizontalOffset(): Int =
839 if (codeWrap.enabled) 0 else max(0, contentWidthPx - width)
822840
823 fun maxHorizontalOffset(target: HorizontalPanTarget): Int =841 fun maxHorizontalOffset(target: HorizontalPanTarget): Int =
824 if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) {842 if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) {
...@@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) {
844 }862 }
845863
846 private fun rebuildOffsets() {864 private fun rebuildOffsets() {
865 layoutRows()
866 requestLayout()
867 invalidate()
868 }
869
870 private fun layoutRows() {
871 codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width)
847 rowOffsets = IntArray(rows.size + 1)872 rowOffsets = IntArray(rows.size + 1)
848 rows.forEachIndexed { index, row ->873 rows.forEachIndexed { index, row ->
849 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)874 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)
850 }875 }
851 setVerticalOffset(verticalOffset)876 setVerticalOffset(verticalOffset)
877 setHorizontalOffset(horizontalOffset)
852 clampHeaderPathOffsets()878 clampHeaderPathOffsets()
853 requestLayout()
854 invalidate()
855 }879 }
856880
857 private fun rowHeight(row: DiffRow): Int = when (row.kind) {881 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
...@@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) {
862 } else {886 } else {
863 (124 * density).toInt()887 (124 * density).toInt()
864 }888 }
889 "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt())
865 else -> style.rowHeightPx.toInt()890 else -> style.rowHeightPx.toInt()
866 }.coerceAtLeast(1)891 }.coerceAtLeast(1)
867892
...@@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) {...@@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) {
1191 )1216 )
1192 }1217 }
11931218
1194 val tokens = tokensByRowId[row.id]1219 // Wrapped rows keep the line number and first code line in the first row-height band.
1220 val lines = codeWrap.lines(row.id)
1221 val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt())
1195 drawScrollableCode(canvas, top, bottom) { codeX ->1222 drawScrollableCode(canvas, top, bottom) { codeX ->
1196 drawing.configureCodePaint(theme.text, 0, style)1223 drawing.configureCodePaint(theme.text, 0, style)
1197 drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom)1224 drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines)
1198 if (tokens.isNullOrEmpty()) {1225 val baseline = lines.baseline(top, firstLineBottom, textPaint)
1199 canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint)1226 drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines)
1200 } else {
1201 var x = codeX
1202 tokens.forEach { token ->
1203 drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style)
1204 canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint)
1205 x += textPaint.measureText(token.content)
1206 }
1207 }
1208 }1227 }
12091228
1210 drawLineNumber(canvas, row, top, bottom)1229 drawLineNumber(canvas, row, top, firstLineBottom)
1211 }1230 }
12121231
1213 private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {1232 private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt created+150
...@@ -0,0 +1,150 @@
1package expo.modules.t3reviewdiff
2
3import android.graphics.Bitmap
4import android.graphics.Canvas
5import android.graphics.Color
6import android.graphics.Typeface
7import android.text.Spanned
8import android.text.TextPaint
9import android.text.style.BackgroundColorSpan
10import org.junit.Assert.assertEquals
11import org.junit.Assert.assertNotSame
12import org.junit.Assert.assertSame
13import org.junit.Assert.assertTrue
14import org.junit.Test
15import org.junit.runner.RunWith
16import org.robolectric.RobolectricTestRunner
17import org.robolectric.annotation.Config
18import org.robolectric.annotation.GraphicsMode
19
20@RunWith(RobolectricTestRunner::class)
21@Config(sdk = [35], manifest = Config.NONE)
22@GraphicsMode(GraphicsMode.Mode.NATIVE)
23class ReviewDiffCodeLayoutTest {
24 private val paint = TextPaint().apply {
25 color = Color.WHITE
26 textSize = 24f
27 typeface = Typeface.MONOSPACE
28 }
29
30 @Test
31 fun unicodeAndTabsFitWithoutSplittingClusters() {
32 val fixtures = listOf("漢字表示", "e\u0301", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue ")
33 for (fixture in fixtures) {
34 val text = fixture.repeat(40)
35 for (width in listOf(180, 280, 420)) {
36 val layout = requireNotNull(createCodeLines(text, paint, width).nativeLayout)
37 assertInkFits(layout, width, fixture)
38 assertLinesFit(layout, fixture, width)
39 }
40 }
41 }
42
43 private fun assertLinesFit(layout: android.text.StaticLayout, fixture: String, width: Int) {
44 val text = layout.text
45 for (line in 0 until layout.lineCount) {
46 if (!fixture.contains('\t')) {
47 assertTrue("$fixture line $line at $width", layout.getLineMax(line) <= width + 1)
48 }
49 val start = layout.getLineStart(line)
50 assertTrue(start == 0 || !Character.isLowSurrogate(text[start]))
51 if (fixture == "👨‍👩‍👧‍👦" || fixture == "e\u0301") {
52 assertEquals(0, start % fixture.length)
53 }
54 }
55 }
56
57 private fun assertInkFits(layout: android.text.StaticLayout, width: Int, fixture: String) {
58 val bitmap = Bitmap.createBitmap(width + 40, layout.height, Bitmap.Config.ARGB_8888)
59 layout.draw(Canvas(bitmap))
60 for (x in width + 1 until bitmap.width) {
61 for (y in 0 until bitmap.height) {
62 assertEquals("$fixture ink outside width $width", 0, Color.alpha(bitmap.getPixel(x, y)))
63 }
64 }
65 bitmap.recycle()
66 }
67
68 @Test
69 fun asciiSegmentsCoverTheWholeLineAndFit() {
70 val text = "const value = 123; ".repeat(100)
71 val lines = createCodeLines(text, paint, 280)
72 val pieces = lines.starts.indices.map {
73 text.substring(lines.starts[it], lines.end(it, text.length))
74 }
75 assertEquals(text, pieces.joinToString(""))
76 assertTrue(pieces.all { paint.measureText(it) <= 280 })
77 }
78
79 @Test
80 fun changingCommentHeightReusesCodeButWidthAndContentInvalidateIt() {
81 val cache = CodeLayoutCache()
82 val row = row("漢字".repeat(100))
83 val comment = row.copy(kind = "comment", id = "comment", content = "", commentText = "Before")
84 val style = DiffStyle.defaults(1f).copy(wordWrap = true)
85 val theme = DiffTheme.fallback("light")
86 val first = cache.layout(
87 listOf(row, comment),
88 emptyMap(),
89 paint,
90 style,
91 theme,
92 280
93 ).lines(row.id)
94 val second = cache.layout(
95 listOf(row, comment.copy(commentText = "After")),
96 emptyMap(),
97 paint,
98 style,
99 theme,
100 280,
101 ).lines(row.id)
102 assertSame(first, second)
103 val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id)
104 assertNotSame(first, narrow)
105 assertTrue(narrow.extraHeight > first.extraHeight)
106 val edited = cache.layout(
107 listOf(row.copy(content = "短い")),
108 emptyMap(),
109 paint,
110 style,
111 theme,
112 180
113 ).lines(row.id)
114 assertTrue(edited.extraHeight < narrow.extraHeight)
115 assertEquals(
116 0,
117 cache.layout(
118 listOf(row),
119 emptyMap(),
120 paint,
121 style.copy(wordWrap = false),
122 theme,
123 180
124 ).extraHeight(row.id)
125 )
126 }
127
128 @Test
129 fun highlightsUseNativeTextRangesAndSurviveSyntaxArrival() {
130 val cache = CodeLayoutCache()
131 val row = row("漢字".repeat(30)).copy(wordDiffRanges = listOf(DiffWordDiffRange(3, 21)))
132 val style = DiffStyle.defaults(1f).copy(wordWrap = true)
133 val theme = DiffTheme.fallback("light")
134 val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id)
135 val tokens = mapOf(row.id to listOf(DiffToken(row.content, 0xff008800.toInt(), 2)))
136 val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row.id)
137 assertNotSame(initial, highlighted)
138 val text = requireNotNull(highlighted.nativeLayout).text as Spanned
139 val span = text.getSpans(0, text.length, BackgroundColorSpan::class.java).single()
140 assertEquals(3, text.getSpanStart(span))
141 assertEquals(21, text.getSpanEnd(span))
142 }
143
144 private fun row(content: String) = DiffRow(
145 kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null,
146 changeType = "modified", additions = 1, deletions = 0, text = "", content = content,
147 change = "add", oldLineNumber = null, newLineNumber = 1, wordDiffRanges = emptyList(),
148 commentText = "", commentRangeLabel = "", commentSectionTitle = "",
149 )
150}
apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift created+123
...@@ -0,0 +1,123 @@
1import UIKit
2
3/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights.
4final class ReviewDiffCodeLayout: NSObject {
5 // Measurement reuses one engine; only recently drawn rows retain a full TextKit layout.
6 private static var measurer: ReviewDiffTextLayout {
7 let key = "T3ReviewDiff.textMeasurer"
8 if let layout = Thread.current.threadDictionary[key] as? ReviewDiffTextLayout { return layout }
9 let layout = ReviewDiffTextLayout()
10 Thread.current.threadDictionary[key] = layout
11 return layout
12 }
13 private static let drawnLayouts: NSCache<ReviewDiffCodeLayout, ReviewDiffTextLayout> = {
14 let cache = NSCache<ReviewDiffCodeLayout, ReviewDiffTextLayout>()
15 cache.countLimit = 128
16 return cache
17 }()
18 let text: String
19 let starts: [Int]
20 let lineHeight: CGFloat
21 let firstLineHeight: CGFloat
22 let extraHeight: CGFloat
23 private let font: UIFont
24 private let width: CGFloat
25 private let characterWidth: CGFloat
26 let usesNativeLayout: Bool
27
28 init(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) {
29 self.text = text
30 self.font = font
31 self.width = width
32 self.characterWidth = characterWidth
33 lineHeight = ceil(font.lineHeight)
34 if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) {
35 let columns = max(1, Int(width / characterWidth))
36 starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns))
37 firstLineHeight = font.lineHeight
38 extraHeight = CGFloat(starts.count - 1) * lineHeight
39 usesNativeLayout = false
40 } else {
41 let layout = Self.measurer
42 layout.configure(text: text, font: font, width: width, characterWidth: characterWidth)
43 let manager = layout.manager
44 let container = layout.container
45 usesNativeLayout = true
46 starts = [0]
47 firstLineHeight = manager.numberOfGlyphs > 0
48 ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight
49 extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight)
50 }
51 }
52
53 private func nativeLayout() -> ReviewDiffTextLayout {
54 if let cached = Self.drawnLayouts.object(forKey: self) { return cached }
55 let layout = ReviewDiffTextLayout()
56 layout.configure(text: text, font: font, width: width, characterWidth: characterWidth)
57 Self.drawnLayouts.setObject(layout, forKey: self)
58 return layout
59 }
60
61 /// Only colors change when syntax tokens arrive; the measured text and font stay intact.
62 func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) {
63 guard usesNativeLayout else { return }
64 let layout = nativeLayout()
65 guard layout.decorationVersion != version else { return }
66 let storage = layout.storage
67 let fullRange = NSRange(location: 0, length: storage.length)
68 storage.beginEditing()
69 storage.removeAttribute(.foregroundColor, range: fullRange)
70 storage.removeAttribute(.backgroundColor, range: fullRange)
71 text.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: text.length)) { value, range, _ in
72 let intersection = NSIntersectionRange(range, fullRange)
73 if let value, intersection.length > 0 {
74 storage.addAttribute(.foregroundColor, value: value, range: intersection)
75 }
76 }
77 for range in highlights {
78 let intersection = NSIntersectionRange(range, fullRange)
79 if intersection.length > 0 {
80 storage.addAttribute(.backgroundColor, value: color, range: intersection)
81 }
82 }
83 storage.endEditing()
84 layout.decorationVersion = version
85 }
86
87 func draw(at origin: CGPoint, clip: CGRect) {
88 guard usesNativeLayout else { return }
89 let layout = nativeLayout()
90 let manager = layout.manager
91 let container = layout.container
92 let visible = clip.offsetBy(dx: -origin.x, dy: -origin.y)
93 let range = manager.glyphRange(forBoundingRect: visible, in: container)
94 manager.drawBackground(forGlyphRange: range, at: origin)
95 manager.drawGlyphs(forGlyphRange: range, at: origin)
96 }
97}
98
99private final class ReviewDiffTextLayout {
100 let storage = NSTextStorage()
101 let manager = NSLayoutManager()
102 let container = NSTextContainer(size: .zero)
103 var decorationVersion = -1
104
105 init() {
106 container.lineFragmentPadding = 0
107 container.lineBreakMode = .byCharWrapping
108 manager.addTextContainer(container)
109 storage.addLayoutManager(manager)
110 }
111
112 func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) {
113 let paragraph = NSMutableParagraphStyle()
114 paragraph.lineBreakMode = .byCharWrapping
115 paragraph.tabStops = []
116 paragraph.defaultTabInterval = characterWidth * 4
117 container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude)
118 storage.setAttributedString(NSAttributedString(string: text, attributes: [
119 .font: font, .ligature: 0, .paragraphStyle: paragraph,
120 ]))
121 manager.ensureLayout(for: container)
122 }
123}
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+222-62
...@@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme {...@@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme {
137}137}
138138
139private struct ReviewDiffNativeStylePayload: Decodable {139private struct ReviewDiffNativeStylePayload: Decodable {
140 let wordWrap: Bool?
140 let rowHeight: Double?141 let rowHeight: Double?
141 let contentWidth: Double?142 let contentWidth: Double?
142 let changeBarWidth: Double?143 let changeBarWidth: Double?
...@@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable {...@@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable {
170}171}
171172
172private struct ReviewDiffNativeStyle {173private struct ReviewDiffNativeStyle {
174 let wordWrap: Bool
173 let rowHeight: CGFloat175 let rowHeight: CGFloat
174 let contentWidth: CGFloat176 let contentWidth: CGFloat
175 let changeBarWidth: CGFloat177 let changeBarWidth: CGFloat
...@@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle {...@@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle {
203205
204 static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {206 static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {
205 ReviewDiffNativeStyle(207 ReviewDiffNativeStyle(
208 wordWrap: payload?.wordWrap ?? false,
206 rowHeight: metric(payload?.rowHeight, fallback: 24),209 rowHeight: metric(payload?.rowHeight, fallback: 24),
207 contentWidth: metric(payload?.contentWidth, fallback: 2800),210 contentWidth: metric(payload?.contentWidth, fallback: 2800),
208 changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),211 changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),
...@@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle {...@@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle {
277280
278 func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {281 func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {
279 ReviewDiffNativeStyle(282 ReviewDiffNativeStyle(
283 wordWrap: wordWrap,
280 rowHeight: rowHeight ?? self.rowHeight,284 rowHeight: rowHeight ?? self.rowHeight,
281 contentWidth: contentWidth ?? self.contentWidth,285 contentWidth: contentWidth ?? self.contentWidth,
282 changeBarWidth: changeBarWidth,286 changeBarWidth: changeBarWidth,
...@@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
434 guard let self, generation == self.rowsDecodeGeneration else {438 guard let self, generation == self.rowsDecodeGeneration else {
435 return439 return
436 }440 }
437 self.rows = decodedRows441 self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in
438 self.contentView.rows = decodedRows442 generation == self?.rowsDecodeGeneration
439 self.hasAppliedInitialRowIndex = false443 }, completion: { [weak self] in
440 self.lastVisibleFileId = nil444 guard let self, generation == self.rowsDecodeGeneration else { return }
441 self.emitDebug("rows-decoded", [445 self.rows = decodedRows
442 "rows": decodedRows.count,446 self.contentView.rows = decodedRows
443 "firstKind": decodedRows.first?.kind ?? "none",447 self.hasAppliedInitialRowIndex = false
444 ])448 self.lastVisibleFileId = nil
445 self.updateContentMetrics()449 self.emitDebug("rows-decoded", [
446 self.applyPendingScrollIfNeeded()450 "rows": decodedRows.count,
451 "firstKind": decodedRows.first?.kind ?? "none",
452 ])
453 self.updateContentMetrics()
454 self.applyPendingScrollIfNeeded()
455 })
447 }456 }
448 } catch {457 } catch {
449 let message = error.localizedDescription458 let message = error.localizedDescription
...@@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
652661
653 private func updateContentMetrics() {662 private func updateContentMetrics() {
654 let style = contentView.style663 let style = contentView.style
664 contentView.viewportWidth = bounds.width
655 let height = max(bounds.height, contentView.contentHeight)665 let height = max(bounds.height, contentView.contentHeight)
656 let width = bounds.width666 let width = bounds.width
657 scrollView.contentSize = CGSize(width: bounds.width, height: height)667 scrollView.contentSize = CGSize(width: bounds.width, height: height)
...@@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {...@@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
661 width: max(width, 1),671 width: max(width, 1),
662 height: max(bounds.height, 1)672 height: max(bounds.height, 1)
663 )673 )
664 contentView.viewportWidth = bounds.width
665 contentView.verticalOffset = scrollView.contentOffset.y674 contentView.verticalOffset = scrollView.contentOffset.y
666 contentView.invalidateVisibleViewport()675 contentView.invalidateVisibleViewport()
667 contentView.setNeedsDisplay()676 contentView.setNeedsDisplay()
...@@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
929 headerPathOffsetsByFileId.removeAll()938 headerPathOffsetsByFileId.removeAll()
930 activePanFileId = nil939 activePanFileId = nil
931 activePanKind = nil940 activePanKind = nil
941 codeDecorationVersion += 1
932 tokenAttributedStringsByRowId.removeAll()942 tokenAttributedStringsByRowId.removeAll()
933 rebuildRowLayout()943 rebuildRowLayout()
934 setNeedsDisplayForVisibleBounds()944 setNeedsDisplayForVisibleBounds()
...@@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
936 }946 }
937 var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {947 var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {
938 didSet {948 didSet {
949 codeDecorationVersion += 1
939 tokenAttributedStringsByRowId.removeAll()950 tokenAttributedStringsByRowId.removeAll()
940 clampHorizontalOffsets()951 clampHorizontalOffsets()
941 setNeedsDisplayForVisibleBounds()952 setNeedsDisplayForVisibleBounds()
...@@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
976 }987 }
977 var style = ReviewDiffNativeStyle.resolve(nil) {988 var style = ReviewDiffNativeStyle.resolve(nil) {
978 didSet {989 didSet {
990 codeDecorationVersion += 1
979 tokenAttributedStringsByRowId.removeAll()991 tokenAttributedStringsByRowId.removeAll()
980 rebuildRowLayout()992 rebuildRowLayout()
981 clampHorizontalOffsets()993 clampHorizontalOffsets()
...@@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
984 }996 }
985 var viewportWidth: CGFloat = 0 {997 var viewportWidth: CGFloat = 0 {
986 didSet {998 didSet {
999 // Wrapped rows take their height from the width, so a new width is a new layout.
1000 if style.wordWrap, viewportWidth != oldValue {
1001 rebuildRowLayout()
1002 }
987 clampHorizontalOffsets()1003 clampHorizontalOffsets()
988 setNeedsDisplayForVisibleBounds()1004 setNeedsDisplayForVisibleBounds()
989 }1005 }
...@@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
992 var theme = ReviewDiffNativeTheme.resolve("light") {1008 var theme = ReviewDiffNativeTheme.resolve("light") {
993 didSet {1009 didSet {
994 tokenColorsByHex.removeAll()1010 tokenColorsByHex.removeAll()
1011 codeDecorationVersion += 1
995 tokenAttributedStringsByRowId.removeAll()1012 tokenAttributedStringsByRowId.removeAll()
996 setNeedsDisplayForVisibleBounds()1013 setNeedsDisplayForVisibleBounds()
997 }1014 }
...@@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1004 private var tokenColorsByHex: [String: UIColor] = [:]1021 private var tokenColorsByHex: [String: UIColor] = [:]
1005 private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]1022 private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]
1006 private var codeCharacterWidth: CGFloat = 81023 private var codeCharacterWidth: CGFloat = 8
1024 /// Columns per visual line while word wrap is on; nil while code rows pan horizontally.
1025 private var codeWrapColumns: Int?
1026 /// Text geometry survives comment height changes; width, font, and content invalidate it.
1027 private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:]
1028 private var codeLayoutWidth: CGFloat = 0
1029 private var codeLayoutFont: UIFont?
1030 private var codeDecorationVersion = 0
1007 private var panStartHorizontalOffset: CGFloat = 01031 private var panStartHorizontalOffset: CGFloat = 0
1008 private var activePanFileId: String?1032 private var activePanFileId: String?
1009 private var activePanKind: ReviewDiffHorizontalPanKind?1033 private var activePanKind: ReviewDiffHorizontalPanKind?
...@@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1029 stickyWidth + style.codePadding1053 stickyWidth + style.codePadding
1030 }1054 }
10311055
1056 /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines.
1032 private func height(for row: ReviewDiffNativeRow) -> CGFloat {1057 private func height(for row: ReviewDiffNativeRow) -> CGFloat {
1033 if row.kind == "file" {1058 if row.kind == "file" {
1034 return style.fileHeaderHeight1059 return style.fileHeaderHeight
...@@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1045 return style.rowHeight1070 return style.rowHeight
1046 }1071 }
10471072
1073 /// Laid-out height, including wrapped lines. Requires a layout built from the current rows.
1074 private func height(at index: Int) -> CGFloat {
1075 let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight
1076 return nextOffset - rowOffsets[index]
1077 }
1078
1079 private var codeWrapLineHeight: CGFloat {
1080 ceil(codeFont.lineHeight)
1081 }
1082
1048 func frameForRow(at index: Int) -> CGRect? {1083 func frameForRow(at index: Int) -> CGRect? {
1049 guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {1084 guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {
1050 return nil1085 return nil
...@@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1054 x: 0,1089 x: 0,
1055 y: rowOffsets[index],1090 y: rowOffsets[index],
1056 width: max(viewportWidth, 1),1091 width: max(viewportWidth, 1),
1057 height: height(for: rows[index])1092 height: height(at: index)
1058 )1093 )
1059 }1094 }
10601095
1096 /// Shape new content on the existing decode worker before publishing rows to the UI.
1097 func prepareRows(
1098 _ rows: [ReviewDiffNativeRow],
1099 on queue: DispatchQueue,
1100 isCurrent: @escaping () -> Bool,
1101 completion: @escaping () -> Void
1102 ) {
1103 let font = codeFont
1104 let width = viewportWidth - codeStartX - style.codePadding
1105 let characterWidth = monospaceCharacterWidth(font: font)
1106 guard style.wordWrap, width >= characterWidth, characterWidth > 0 else {
1107 completion()
1108 return
1109 }
1110 let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:]
1111 queue.async { [weak self] in
1112 var layouts: [String: ReviewDiffCodeLayout] = [:]
1113 for row in rows where row.kind == "line" {
1114 guard let text = row.content else { continue }
1115 if let previous = cached[row.id], previous.text == text {
1116 layouts[row.id] = previous
1117 } else {
1118 layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth)
1119 }
1120 }
1121 DispatchQueue.main.async { [weak self] in
1122 guard let self, isCurrent() else { return }
1123 if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width {
1124 self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion)
1125 return
1126 }
1127 self.codeLayoutWidth = width
1128 self.codeLayoutFont = font
1129 self.codeLayoutsByRowId = layouts
1130 completion()
1131 }
1132 }
1133 }
1134
1061 private func rebuildRowLayout() {1135 private func rebuildRowLayout() {
1062 var nextOffsets: [CGFloat] = []1136 var nextOffsets: [CGFloat] = []
1063 var nextFileHeaderRowIndices: [Int] = []1137 var nextFileHeaderRowIndices: [Int] = []
1064 nextOffsets.reserveCapacity(rows.count)1138 nextOffsets.reserveCapacity(rows.count)
1065 var maxColumnCountsByFileId: [String: Int] = [:]1139 var maxColumnCountsByFileId: [String: Int] = [:]
1140 var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:]
1066 var offset: CGFloat = 01141 var offset: CGFloat = 0
1142 let font = codeFont
1143 let characterWidth = monospaceCharacterWidth(font: font)
1144 let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding
1145 let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth
1146 ? Int(wrapAvailableWidth / characterWidth)
1147 : nil
1148 if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font {
1149 codeLayoutsByRowId.removeAll()
1150 codeLayoutWidth = wrapAvailableWidth
1151 codeLayoutFont = font
1152 }
10671153
1068 for (index, row) in rows.enumerated() {1154 for (index, row) in rows.enumerated() {
1069 nextOffsets.append(offset)1155 nextOffsets.append(offset)
1070 if row.kind == "file" {1156 if row.kind == "file" {
1071 nextFileHeaderRowIndices.append(index)1157 nextFileHeaderRowIndices.append(index)
1072 }1158 }
1073 offset += height(for: row)1159 var rowHeight = height(for: row)
10741160
1075 let fileId = resolvedFileId(for: row)1161 let fileId = resolvedFileId(for: row)
1076 switch row.kind {1162 switch row.kind {
1077 case "line":1163 case "line":
1078 maxColumnCountsByFileId[fileId] = max(1164 // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws.
1079 maxColumnCountsByFileId[fileId] ?? 0,1165 let columnCount = row.content?.utf16.count ?? 0
1080 row.content?.count ?? 01166 maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount)
1081 )1167 if wrapColumns != nil, let content = row.content {
1168 let cached = codeLayoutsByRowId[row.id]
1169 let layout: ReviewDiffCodeLayout
1170 if let cached, cached.text == content {
1171 layout = cached
1172 } else {
1173 layout = ReviewDiffCodeLayout(
1174 text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth
1175 )
1176 }
1177 nextCodeLayouts[row.id] = layout
1178 if rowHeight > 0 {
1179 rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight
1180 }
1181 }
1082 case "hunk":1182 case "hunk":
1083 maxColumnCountsByFileId[fileId] = max(1183 maxColumnCountsByFileId[fileId] = max(
1084 maxColumnCountsByFileId[fileId] ?? 0,1184 maxColumnCountsByFileId[fileId] ?? 0,
1085 row.text?.count ?? 01185 row.text?.utf16.count ?? 0
1086 )1186 )
1087 default:1187 default:
1088 continue1188 break
1089 }1189 }
1190 offset += rowHeight
1090 }1191 }
10911192
1092 let characterWidth = monospaceCharacterWidth(font: codeFont)
1093 codeCharacterWidth = characterWidth1193 codeCharacterWidth = characterWidth
1194 codeWrapColumns = wrapColumns
1195 codeLayoutsByRowId = nextCodeLayouts
1094 contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in1196 contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
1095 let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 21197 let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
1096 return max(0, min(style.contentWidth, measuredWidth))1198 return max(0, min(style.contentWidth, measuredWidth))
...@@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1498 while lowerBound <= upperBound {1600 while lowerBound <= upperBound {
1499 let midpoint = (lowerBound + upperBound) / 21601 let midpoint = (lowerBound + upperBound) / 2
1500 let rowStart = rowOffsets[midpoint]1602 let rowStart = rowOffsets[midpoint]
1501 let rowEnd = rowStart + height(for: rows[midpoint])1603 let rowEnd = rowStart + height(at: midpoint)
15021604
1503 if absoluteY < rowStart {1605 if absoluteY < rowStart {
1504 upperBound = midpoint - 11606 upperBound = midpoint - 1
...@@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1521 var upperBound = rows.count1623 var upperBound = rows.count
1522 while lowerBound < upperBound {1624 while lowerBound < upperBound {
1523 let midpoint = (lowerBound + upperBound) / 21625 let midpoint = (lowerBound + upperBound) / 2
1524 let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint])1626 let rowEnd = rowOffsets[midpoint] + height(at: midpoint)
15251627
1526 if rowEnd < absoluteY {1628 if rowEnd < absoluteY {
1527 lowerBound = midpoint + 11629 lowerBound = midpoint + 1
...@@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1609 let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {1711 let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {
1610 return maxHeaderPathOffset(for: row)1712 return maxHeaderPathOffset(for: row)
1611 }1713 }
1714 if codeWrapColumns != nil {
1715 return 0
1716 }
16121717
1613 return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))1718 return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))
1614 }1719 }
...@@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
1728 var drawnRowCount = 01833 var drawnRowCount = 0
1729 for rowIndex in firstRowIndex...lastRowIndex {1834 for rowIndex in firstRowIndex...lastRowIndex {
1730 let rowStart = rowOffsets[rowIndex]1835 let rowStart = rowOffsets[rowIndex]
1731 let rowHeight = height(for: rows[rowIndex])1836 let rowHeight = height(at: rowIndex)
1732 if rowHeight <= 0 {1837 if rowHeight <= 0 {
1733 continue1838 continue
1734 }1839 }
...@@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
17861891
1787 private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {1892 private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {
1788 let rowY = rowOffsets[rowIndex] - verticalOffset1893 let rowY = rowOffsets[rowIndex] - verticalOffset
1789 let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))1894 let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex))
17901895
1791 switch row.kind {1896 switch row.kind {
1792 case "file":1897 case "file":
...@@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2172 let horizontalOffset = horizontalOffset(for: fileId)2277 let horizontalOffset = horizontalOffset(for: fileId)
2173 let contentWidth = contentWidth(for: fileId)2278 let contentWidth = contentWidth(for: fileId)
2174 let change = row.change ?? "context"2279 let change = row.change ?? "context"
2280 // Wrapped rows keep the line number and first code line in the first row-height band.
2281 let layout = codeLayoutsByRowId[row.id]
2282 let firstLineRect = CGRect(
2283 x: rect.minX, y: rect.minY, width: rect.width,
2284 height: max(style.rowHeight, layout?.firstLineHeight ?? 0)
2285 )
2175 rowBackground(for: change).setFill()2286 rowBackground(for: change).setFill()
2176 context.fill(rect)2287 context.fill(rect)
21772288
2178 if change == "add" {2289 if change == "add" {
2179 theme.addBar.setFill()2290 theme.addBar.setFill()
2180 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight))2291 context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
2181 } else if change == "delete" {2292 } else if change == "delete" {
2182 drawDeleteStripes(2293 drawDeleteStripes(
2183 rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight),2294 rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height),
2184 context: context2295 context: context
2185 )2296 )
2186 }2297 }
...@@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2193 "\(lineNumber)",2304 "\(lineNumber)",
2194 rect: CGRect(2305 rect: CGRect(
2195 x: style.changeBarWidth,2306 x: style.changeBarWidth,
2196 y: centeredTextY(in: rect, font: lineNumberFont),2307 y: centeredTextY(in: firstLineRect, font: lineNumberFont),
2197 width: style.gutterWidth - style.codePadding,2308 width: style.gutterWidth - style.codePadding,
2198 height: lineNumberFont.lineHeight2309 height: lineNumberFont.lineHeight
2199 ),2310 ),
...@@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2203 }2314 }
22042315
2205 context.saveGState()2316 context.saveGState()
2206 context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))2317 context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height))
2207 let codeTextRect = CGRect(2318 let codeTextRect = CGRect(
2208 x: codeStartX - horizontalOffset,2319 x: codeStartX - horizontalOffset,
2209 y: centeredTextY(in: rect, font: codeFont),2320 y: centeredTextY(in: firstLineRect, font: codeFont),
2210 width: contentWidth,2321 width: contentWidth,
2211 height: codeFont.lineHeight2322 height: codeFont.lineHeight
2212 )2323 )
2213 drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset)2324 if let layout, layout.usesNativeLayout {
2325 let text = tokensByRowId[row.id].map {
2326 tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont)
2327 } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text])
2328 let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : []
2329 layout.decorate(
2330 text: text,
2331 highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map {
2332 NSRange(location: $0.start, length: $0.end - $0.start)
2333 },
2334 color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28),
2335 version: codeDecorationVersion
2336 )
2337 layout.draw(
2338 at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)),
2339 clip: context.boundingBoxOfClipPath
2340 )
2341 context.restoreGState()
2342 return
2343 }
2344 let lineStarts = layout?.starts ?? [0]
2345 drawWordDiffRanges(
2346 row,
2347 lineStarts: lineStarts,
2348 firstLineRect: firstLineRect,
2349 context: context,
2350 horizontalOffset: horizontalOffset
2351 )
2214 if let tokens = tokensByRowId[row.id], !tokens.isEmpty {2352 if let tokens = tokensByRowId[row.id], !tokens.isEmpty {
2215 drawTokenText(2353 let attributedText = tokenAttributedString(
2216 rowId: row.id,2354 rowId: row.id,
2217 tokens,2355 tokens: tokens,
2218 rect: codeTextRect,
2219 fallbackColor: theme.text,2356 fallbackColor: theme.text,
2220 font: codeFont2357 font: codeFont
2221 )2358 )
2359 drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in
2360 let segment = range.length == attributedText.length
2361 ? attributedText
2362 : attributedText.attributedSubstring(from: range)
2363 segment.draw(in: lineRect)
2364 }
2222 } else {2365 } else {
2223 drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont)2366 let content = (row.content ?? "") as NSString
2367 drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in
2368 let segment = range.length == content.length ? content as String : content.substring(with: range)
2369 drawText(segment, rect: lineRect, color: theme.text, font: codeFont)
2370 }
2224 }2371 }
2225 context.restoreGState()2372 context.restoreGState()
2226 }2373 }
22272374
2375 /// Draws the segment starting at each of the row's line starts on its own visual line.
2376 private func drawCodeLines(
2377 length: Int,
2378 lineStarts: [Int],
2379 firstLineRect: CGRect,
2380 draw: (NSRange, CGRect) -> Void
2381 ) {
2382 var lineRect = firstLineRect
2383 let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds
2384 let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight)))
2385 let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight)))
2386 guard first < last else { return }
2387 lineRect.origin.y += CGFloat(first) * codeWrapLineHeight
2388 for line in first..<last {
2389 let lineStart = lineStarts[line]
2390 let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : length
2391 draw(NSRange(location: lineStart, length: lineEnd - lineStart), lineRect)
2392 lineRect.origin.y += codeWrapLineHeight
2393 }
2394 }
2395
2228 private func drawSelectionOverlay(2396 private func drawSelectionOverlay(
2229 _ row: ReviewDiffNativeRow,2397 _ row: ReviewDiffNativeRow,
2230 rect: CGRect,2398 rect: CGRect,
...@@ -2244,7 +2412,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2244,7 +2412,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
22442412
2245 private func drawWordDiffRanges(2413 private func drawWordDiffRanges(
2246 _ row: ReviewDiffNativeRow,2414 _ row: ReviewDiffNativeRow,
2247 rowRect: CGRect,2415 lineStarts: [Int],
2416 firstLineRect: CGRect,
2248 context: CGContext,2417 context: CGContext,
2249 horizontalOffset: CGFloat2418 horizontalOffset: CGFloat
2250 ) {2419 ) {
...@@ -2263,8 +2432,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2263,8 +2432,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2263 } else {2432 } else {
2264 fillColor = theme.deleteBar.withAlphaComponent(0.28)2433 fillColor = theme.deleteBar.withAlphaComponent(0.28)
2265 }2434 }
2266 let highlightHeight = max(4, min(rowRect.height - 4, codeFont.lineHeight))2435 let highlightHeight = max(4, min(firstLineRect.height - 4, codeFont.lineHeight))
2267 let highlightY = rowRect.midY - highlightHeight / 22436 let highlightY = firstLineRect.midY - highlightHeight / 2
22682437
2269 fillColor.setFill()2438 fillColor.setFill()
2270 for range in ranges {2439 for range in ranges {
...@@ -2272,15 +2441,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2272,15 +2441,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2272 continue2441 continue
2273 }2442 }
22742443
2275 let startX = codeStartX - horizontalOffset + CGFloat(range.start) * codeCharacterWidth2444 // A wrapped row splits the highlight at each visual line boundary.
2276 let width = max(2, CGFloat(range.end - range.start) * codeCharacterWidth)2445 for (line, lineStart) in lineStarts.enumerated() {
2277 let highlightRect = CGRect(2446 let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : Int.max
2278 x: startX,2447 let start = max(range.start, lineStart)
2279 y: highlightY,2448 let end = min(range.end, lineEnd)
2280 width: width,2449 guard end > start else {
2281 height: highlightHeight2450 continue
2282 )2451 }
2283 UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()2452 let highlightRect = CGRect(
2453 x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth,
2454 y: highlightY + CGFloat(line) * codeWrapLineHeight,
2455 width: max(2, CGFloat(end - start) * codeCharacterWidth),
2456 height: highlightHeight
2457 )
2458 UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()
2459 }
2284 }2460 }
2285 }2461 }
22862462
...@@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {...@@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
2448 return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)2624 return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)
2449 }2625 }
24502626
2451 private func drawTokenText(
2452 rowId: String,
2453 _ tokens: [ReviewDiffNativeToken],
2454 rect: CGRect,
2455 fallbackColor: UIColor,
2456 font: UIFont
2457 ) {
2458 let attributedText = tokenAttributedString(
2459 rowId: rowId,
2460 tokens: tokens,
2461 fallbackColor: fallbackColor,
2462 font: font
2463 )
2464 attributedText.draw(in: rect)
2465 }
2466
2467 private func tokenAttributedString(2627 private func tokenAttributedString(
2468 rowId: String,2628 rowId: String,
2469 tokens: [ReviewDiffNativeToken],2629 tokens: [ReviewDiffNativeToken],
apps/mobile/modules/t3-review-diff/tests/ios/main.swift created+60
...@@ -0,0 +1,60 @@
1import UIKit
2
3func check(_ passed: Bool, _ message: String = "Failed layout check") {
4 if !passed {
5 FileHandle.standardError.write(Data((message + "\n").utf8))
6 exit(1)
7 }
8}
9
10// Runs the production layout against UIKit through Mac Catalyst, without launching an app.
11let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
12let characterWidth = ("M" as NSString).size(withAttributes: [.font: font]).width
13let fixtures = ["漢字表示", "e\u{301}", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue "]
14var cases = 0
15for fixture in fixtures {
16 let text = String(repeating: fixture, count: 40)
17 var previousHeight = CGFloat.greatestFiniteMagnitude
18 for width: CGFloat in [180, 280, 420] {
19 let layout = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth)
20 let height = layout.firstLineHeight + layout.extraHeight
21 check(height <= previousHeight, "Wider text must not require more height")
22 previousHeight = height
23 let fullRange = NSRange(location: 0, length: text.utf16.count)
24 let attributed = NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.black])
25 layout.decorate(text: attributed, highlights: [], color: .clear, version: 0)
26 let format = UIGraphicsImageRendererFormat()
27 format.scale = 1
28 format.opaque = false
29 format.preferredRange = .standard
30 let size = CGSize(width: width + 40, height: ceil(height))
31 let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in
32 layout.draw(at: .zero, clip: CGRect(origin: .zero, size: size))
33 }
34 let bitmap = image.cgImage!
35 let data = bitmap.dataProvider!.data!
36 let bytes = CFDataGetBytePtr(data)!
37 // Render without a viewport clip so an overflowing glyph cannot hide behind clipping.
38 for y in 0..<bitmap.height {
39 for x in Int(width) + 1..<bitmap.width {
40 check(bytes[y * bitmap.bytesPerRow + x * 4 + 3] == 0, "Ink outside viewport: \(fixture) x=\(x) y=\(y) bpp=\(bitmap.bitsPerPixel) alpha=\(bitmap.alphaInfo.rawValue)")
41 }
42 }
43 // Updating highlights and syntax colors must preserve measured geometry.
44 layout.decorate(text: attributed, highlights: [fullRange], color: .green, version: 1)
45 check(layout.firstLineHeight + layout.extraHeight == height)
46 cases += 1
47 }
48}
49let ascii = String(repeating: "const value = 123; ", count: 100)
50let layout = ReviewDiffCodeLayout(text: ascii, font: font, width: 280, characterWidth: characterWidth)
51let string = ascii as NSString
52var reconstructed = ""
53for (index, start) in layout.starts.enumerated() {
54 let end = index + 1 < layout.starts.count ? layout.starts[index + 1] : string.length
55 let segment = string.substring(with: NSRange(location: start, length: end - start))
56 check((segment as NSString).size(withAttributes: [.font: font]).width <= 280)
57 reconstructed += segment
58}
59check(reconstructed == ascii)
60print("Passed \(cases) Unicode/width cases, color-update geometry, and ASCII coverage")
apps/mobile/modules/t3-review-diff/tests/run-ios.sh created+12
...@@ -0,0 +1,12 @@
1#!/bin/bash
2set -euo pipefail
3module_dir=$(cd "$(dirname "$0")/.." && pwd)
4sdk=$(xcrun --sdk macosx --show-sdk-path)
5compiler=$(xcrun --find swiftc)
6output=$(mktemp -d)
7trap 'rm -rf "$output"' EXIT
8"$compiler" -O -target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" \
9 -F "$sdk/System/iOSSupport/System/Library/Frameworks" \
10 "$module_dir/ios/ReviewDiffCodeLayout.swift" "$module_dir/tests/ios/main.swift" \
11 -o "$output/layout-tests"
12"$output/layout-tests"
apps/mobile/src/features/review/ReviewCommentCard.tsx+2-1
...@@ -110,8 +110,9 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: {...@@ -110,8 +110,9 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: {
110 () => JSON.stringify(nativeReviewDiffTheme),110 () => JSON.stringify(nativeReviewDiffTheme),
111 [nativeReviewDiffTheme],111 [nativeReviewDiffTheme],
112 );112 );
113 // The card's height is sized from its row count, so its snippet stays unwrapped.
113 const nativeStyleJson = useMemo(114 const nativeStyleJson = useMemo(
114 () => JSON.stringify(nativeReviewDiffStyle),115 () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }),
115 [nativeReviewDiffStyle],116 [nativeReviewDiffStyle],
116 );117 );
117 const nativeDiffHeight = useMemo(118 const nativeDiffHeight = useMemo(
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+6-1
...@@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string {...@@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string {
63 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;63 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
64}64}
6565
66export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {66/** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */
67export function createNativeReviewDiffStyle(
68 codeSurface: ResolvedMobileCodeSurface,
69 wordWrap: boolean,
70) {
67 return {71 return {
72 wordWrap,
68 rowHeight: codeSurface.rowHeight,73 rowHeight: codeSurface.rowHeight,
69 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,74 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
70 changeBarWidth: 4,75 changeBarWidth: 4,
apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts created+74
...@@ -0,0 +1,74 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { createReviewDiffHighlightScheduler } from "./reviewDiffHighlightScheduler";
4
5describe("review diff highlighting while scrolling", () => {
6 beforeEach(() => vi.useFakeTimers());
7 afterEach(() => vi.useRealTimers());
8
9 it("keeps requesting new rows during gradual scrolling through a large diff", () => {
10 const request = vi.fn();
11 const scheduler = createReviewDiffHighlightScheduler(request);
12 for (let firstRowIndex = 1; firstRowIndex <= 1_674; firstRowIndex++) {
13 scheduler.update({ firstRowIndex, lastRowIndex: firstRowIndex + 80 });
14 vi.advanceTimersByTime(16);
15 }
16 expect(request.mock.calls.length).toBeGreaterThan(100);
17 vi.advanceTimersByTime(150);
18 expect(request).toHaveBeenLastCalledWith({ firstRowIndex: 1_674, lastRowIndex: 1_754 });
19 });
20
21 it("highlights the settled viewport even below the movement threshold", () => {
22 const request = vi.fn();
23 const scheduler = createReviewDiffHighlightScheduler(request);
24 scheduler.update({ firstRowIndex: 2, lastRowIndex: 82 });
25 vi.advanceTimersByTime(100);
26 scheduler.update({ firstRowIndex: 3, lastRowIndex: 83 });
27 vi.advanceTimersByTime(100);
28 expect(request).not.toHaveBeenCalled();
29 vi.advanceTimersByTime(50);
30 expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 3, lastRowIndex: 83 });
31 });
32
33 it("does not let repeated draw events starve the settled refresh", () => {
34 const request = vi.fn();
35 const scheduler = createReviewDiffHighlightScheduler(request);
36 for (let i = 0; i < 10; i++) {
37 scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 });
38 vi.advanceTimersByTime(30);
39 }
40 expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 });
41 });
42
43 it("requests large jumps and reverse scrolling immediately without stale timers", () => {
44 const request = vi.fn();
45 const scheduler = createReviewDiffHighlightScheduler(request);
46 scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 });
47 scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 });
48 scheduler.update({ firstRowIndex: 0, lastRowIndex: 80 });
49 vi.runAllTimers();
50 expect(request.mock.calls).toEqual([
51 [{ firstRowIndex: 1_000, lastRowIndex: 1_080 }],
52 [{ firstRowIndex: 0, lastRowIndex: 80 }],
53 ]);
54 });
55
56 it("cancels pending work on disposal and resets the range for a new diff", () => {
57 const request = vi.fn();
58 const scheduler = createReviewDiffHighlightScheduler(request);
59 scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 });
60 scheduler.cancel();
61 vi.runAllTimers();
62 expect(request).not.toHaveBeenCalled();
63 scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 });
64 request.mockClear();
65 scheduler.update({ firstRowIndex: 1_001, lastRowIndex: 1_081 });
66 scheduler.reset();
67 vi.runAllTimers();
68 expect(request).not.toHaveBeenCalled();
69 scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 });
70 expect(request).not.toHaveBeenCalled();
71 vi.advanceTimersByTime(150);
72 expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 });
73 });
74});
apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts created+51
...@@ -0,0 +1,51 @@
1export interface NativeReviewVisibleRange {
2 readonly firstRowIndex: number;
3 readonly lastRowIndex: number;
4}
5
6export function createReviewDiffHighlightScheduler(
7 request: (range: NativeReviewVisibleRange) => void,
8) {
9 let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 };
10 let visibleRange = requestedRange;
11 let timer: ReturnType<typeof setTimeout> | undefined;
12
13 const cancel = () => {
14 clearTimeout(timer);
15 timer = undefined;
16 };
17 const flush = () => {
18 cancel();
19 requestedRange = visibleRange;
20 request(visibleRange);
21 };
22
23 return {
24 update(nextRange: NativeReviewVisibleRange) {
25 if (
26 nextRange.firstRowIndex === visibleRange.firstRowIndex &&
27 nextRange.lastRowIndex === visibleRange.lastRowIndex
28 ) {
29 return;
30 }
31 visibleRange = nextRange;
32 cancel();
33 // Accumulate small scroll events relative to the last request, not each other.
34 const movedRows =
35 Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) +
36 Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex);
37 if (movedRows >= 20) {
38 flush();
39 } else if (movedRows > 0) {
40 // Cover the final viewport even when scrolling stops below the threshold.
41 timer = setTimeout(flush, 150);
42 }
43 },
44 reset() {
45 cancel();
46 requestedRange = { firstRowIndex: 0, lastRowIndex: 80 };
47 visibleRange = requestedRange;
48 },
49 cancel,
50 };
51}
apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts+14-27
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useEffect, useRef, useState } from "react";
22
3import {3import {
4 highlightNativeReviewDiffVisibleRows,4 highlightNativeReviewDiffVisibleRows,
...@@ -8,10 +8,10 @@ import {...@@ -8,10 +8,10 @@ import {
8import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface";8import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface";
9import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes";9import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes";
1010
11interface NativeReviewVisibleRange {11import {
12 readonly firstRowIndex: number;12 createReviewDiffHighlightScheduler,
13 readonly lastRowIndex: number;13 type NativeReviewVisibleRange,
14}14} from "./reviewDiffHighlightScheduler";
1515
16function createEmptyTokenPatch(resetKey: string): string {16function createEmptyTokenPatch(resetKey: string): string {
17 return JSON.stringify({ resetKey, tokensByRowId: {} });17 return JSON.stringify({ resetKey, tokensByRowId: {} });
...@@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: {...@@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: {
43}) {43}) {
44 const { enabled, files, resetKey, rows, scheme } = input;44 const { enabled, files, resetKey, rows, scheme } = input;
45 const highlightedRowIdsRef = useRef<Set<string>>(new Set());45 const highlightedRowIdsRef = useRef<Set<string>>(new Set());
46 const visibleRangeRef = useRef<NativeReviewVisibleRange>({46 const [visibleRange, setVisibleRange] = useState<NativeReviewVisibleRange>({
47 firstRowIndex: 0,47 firstRowIndex: 0,
48 lastRowIndex: 80,48 lastRowIndex: 80,
49 });49 });
50 const visibleChunkIndexRef = useRef(0);50 const visibleChunkIndexRef = useRef(0);
51 const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey));51 const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey));
52 const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0);52 const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange));
5353
54 useEffect(() => {54 useEffect(() => {
55 scheduler.reset();
55 highlightedRowIdsRef.current = new Set();56 highlightedRowIdsRef.current = new Set();
56 visibleChunkIndexRef.current = 0;57 visibleChunkIndexRef.current = 0;
57 visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 };58 setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 });
58 setTokensPatchJson(createEmptyTokenPatch(resetKey));59 setTokensPatchJson(createEmptyTokenPatch(resetKey));
59 if (enabled && rows.length > 0) {60 return () => scheduler.cancel();
60 setVisibleHighlightRequest((request) => request + 1);61 }, [enabled, resetKey, rows.length, scheduler]);
61 }
62 }, [enabled, resetKey, rows.length]);
6362
64 useEffect(() => {63 useEffect(() => {
65 if (!enabled || rows.length === 0) {64 if (!enabled || rows.length === 0) {
...@@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: {...@@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: {
67 }66 }
6867
69 const abortController = new AbortController();68 const abortController = new AbortController();
70 const requestRange = visibleRangeRef.current;69 const requestRange = visibleRange;
71 const engine: NativeReviewDiffHighlightEngine = "native";70 const engine: NativeReviewDiffHighlightEngine = "native";
7271
73 void (async () => {72 void (async () => {
...@@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: {...@@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: {
119 })();118 })();
120119
121 return () => abortController.abort();120 return () => abortController.abort();
122 }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]);121 }, [enabled, files, resetKey, rows, scheme, visibleRange]);
123
124 const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => {
125 const previousRange = visibleRangeRef.current;
126 const movedRows =
127 Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) +
128 Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex);
129
130 visibleRangeRef.current = nextRange;
131 if (movedRows >= 20) {
132 setVisibleHighlightRequest((request) => request + 1);
133 }
134 }, []);
135122
136 return {123 return {
137 tokensPatchJson,124 tokensPatchJson,
138 updateVisibleRange,125 updateVisibleRange: scheduler.update,
139 };126 };
140}127}
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts+2-2
...@@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): {...@@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): {
21 );21 );
22 const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]);22 const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]);
23 const nativeReviewDiffStyle = useMemo(23 const nativeReviewDiffStyle = useMemo(
24 () => createNativeReviewDiffStyle(codeSurface),24 () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak),
25 [codeSurface],25 [appearance.codeWordBreak, codeSurface],
26 );26 );
2727
28 return {28 return {