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 {
88 namespace 'expo.modules.t3reviewdiff'
99 compileSdk rootProject.ext.compileSdkVersion
1010
11 testOptions {
12 unitTests.includeAndroidResources = true
13 }
14
1115 defaultConfig {
1216 minSdkVersion rootProject.ext.minSdkVersion
1317 targetSdkVersion rootProject.ext.targetSdkVersion
......@@ -16,4 +20,12 @@ android {
1620
1721dependencies {
1822 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 }
1931}
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
99import android.graphics.RectF
1010import android.graphics.Shader
1111import android.graphics.Typeface
12import android.text.TextPaint
1213import kotlin.math.max
1314import kotlin.math.min
1415
......@@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) {
177178 textPaint.isUnderlineText = fontStyle and 4 != 0
178179 }
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
180214 fun lineNumberColor(change: String): Int = when (change) {
181215 "add" -> theme.addText
182216 "delete" -> theme.deleteText
......@@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) {
198232 }
199233 }
200234
235 /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */
236 @Suppress("LongParameterList")
201237 fun drawWordDiffRanges(
202238 canvas: Canvas,
203239 row: DiffRow,
204240 codeX: Float,
205241 top: Int,
206 bottom: Int
242 bottom: Int,
243 lines: CodeLines
207244 ) {
245 if (lines.nativeLayout != null) return
208246 if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return
209247 val color = if (row.change == "add") theme.addBar else theme.deleteBar
210248 backgroundPaint.color = withAlpha(color, 71)
......@@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) {
213251 val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight))
214252 val highlightTop = (top + bottom - highlightHeight) / 2f
215253 row.wordDiffRanges.forEach { range ->
216 val left = codeX + range.start * characterWidth
217 val right = max(left + 2f * density, codeX + range.end * characterWidth)
218 canvas.drawRoundRect(
219 RectF(left, highlightTop, right, highlightTop + highlightHeight),
220 3f * density,
221 3f * density,
222 backgroundPaint,
223 )
254 // A wrapped row splits the highlight at each visual line boundary.
255 lines.starts.forEachIndexed { line, lineStart ->
256 val start = max(range.start, lineStart)
257 val end = min(range.end, lines.end(line, Int.MAX_VALUE))
258 if (end <= start) return@forEachIndexed
259 val left = codeX + (start - lineStart) * characterWidth
260 val right = max(left + 2f * density, left + (end - start) * characterWidth)
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
224314 }
225315 }
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
143143 fun setRowsJson(value: String) {
144144 rowsDecodeGeneration += 1
145145 val generation = rowsDecodeGeneration
146 val prepareLayout = canvasView.prepareRows()
146147 payloadDecodeExecutor.execute {
147148 val decodedRows = parseRows(value)
149 val codeLayouts = prepareLayout(decodedRows)
148150 post {
149151 if (generation != rowsDecodeGeneration) return@post
152 canvasView.useCodeLayouts(codeLayouts)
150153 rows = decodedRows
151154 lastVisibleFileId = null
152155 rebuildVisibleRows()
......@@ -460,7 +463,7 @@ internal data class DiffWordDiffRange(
460463 val end: Int
461464)
462465
463private data class DiffToken(
466internal data class DiffToken(
464467 val content: String,
465468 val color: Int?,
466469 val fontStyle: Int
......@@ -543,6 +546,7 @@ internal data class DiffTheme(
543546}
544547
545548internal data class DiffStyle(
549 val wordWrap: Boolean,
546550 val rowHeightPx: Float,
547551 val gutterWidthPx: Float,
548552 val codePaddingPx: Float,
......@@ -564,6 +568,7 @@ internal data class DiffStyle(
564568) {
565569 companion object {
566570 fun defaults(density: Float): DiffStyle = DiffStyle(
571 wordWrap = false,
567572 rowHeightPx = 20f * density,
568573 gutterWidthPx = 72f * density,
569574 codePaddingPx = 10f * density,
......@@ -587,6 +592,7 @@ internal data class DiffStyle(
587592 fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try {
588593 val json = JSONObject(value)
589594 DiffStyle(
595 wordWrap = json.optBoolean("wordWrap", fallback.wordWrap),
590596 rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density),
591597 gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density),
592598 codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density),
......@@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) {
684690 },
685691 )
686692 private var rowOffsets = intArrayOf(0)
693
694 private var codeWrap = CodeWrapLayout.NONE
687695 private var verticalOffset = 0
688696 private var horizontalOffset = 0
689697 private val headerPathOffsetsByFileId = mutableMapOf<String, Int>()
......@@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) {
699707 var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()
700708 set(value) {
701709 field = value
710 if (style.wordWrap) rebuildOffsets()
702711 invalidate()
703712 }
704713 var viewedFileIds: Set<String> = emptySet()
......@@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) {
725734 set(value) {
726735 field = value
727736 drawing.theme = value
737 if (style.wordWrap) rebuildOffsets()
728738 invalidate()
729739 }
730740 var style: DiffStyle = DiffStyle.defaults(density)
......@@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) {
742752 var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null
743753 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
745760 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
746761 setMeasuredDimension(
747762 MeasureSpec.getSize(widthMeasureSpec),
......@@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) {
751766
752767 override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
753768 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()
754771 setVerticalOffset(verticalOffset)
755772 setHorizontalOffset(horizontalOffset)
756773 clampHeaderPathOffsets()
......@@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) {
818835
819836 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
823841 fun maxHorizontalOffset(target: HorizontalPanTarget): Int =
824842 if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) {
......@@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) {
844862 }
845863
846864 private fun rebuildOffsets() {
865 layoutRows()
866 requestLayout()
867 invalidate()
868 }
869
870 private fun layoutRows() {
871 codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width)
847872 rowOffsets = IntArray(rows.size + 1)
848873 rows.forEachIndexed { index, row ->
849874 rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row)
850875 }
851876 setVerticalOffset(verticalOffset)
877 setHorizontalOffset(horizontalOffset)
852878 clampHeaderPathOffsets()
853 requestLayout()
854 invalidate()
855879 }
856880
857881 private fun rowHeight(row: DiffRow): Int = when (row.kind) {
......@@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) {
862886 } else {
863887 (124 * density).toInt()
864888 }
889 "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt())
865890 else -> style.rowHeightPx.toInt()
866891 }.coerceAtLeast(1)
867892
......@@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) {
11911216 )
11921217 }
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())
11951222 drawScrollableCode(canvas, top, bottom) { codeX ->
11961223 drawing.configureCodePaint(theme.text, 0, style)
1197 drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom)
1198 if (tokens.isNullOrEmpty()) {
1199 canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
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 }
1224 drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines)
1225 val baseline = lines.baseline(top, firstLineBottom, textPaint)
1226 drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines)
12081227 }
12091228
1210 drawLineNumber(canvas, row, top, bottom)
1229 drawLineNumber(canvas, row, top, firstLineBottom)
12111230 }
12121231
12131232 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 {
137137}
138138
139139private struct ReviewDiffNativeStylePayload: Decodable {
140 let wordWrap: Bool?
140141 let rowHeight: Double?
141142 let contentWidth: Double?
142143 let changeBarWidth: Double?
......@@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable {
170171}
171172
172173private struct ReviewDiffNativeStyle {
174 let wordWrap: Bool
173175 let rowHeight: CGFloat
174176 let contentWidth: CGFloat
175177 let changeBarWidth: CGFloat
......@@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle {
203205
204206 static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {
205207 ReviewDiffNativeStyle(
208 wordWrap: payload?.wordWrap ?? false,
206209 rowHeight: metric(payload?.rowHeight, fallback: 24),
207210 contentWidth: metric(payload?.contentWidth, fallback: 2800),
208211 changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4),
......@@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle {
277280
278281 func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {
279282 ReviewDiffNativeStyle(
283 wordWrap: wordWrap,
280284 rowHeight: rowHeight ?? self.rowHeight,
281285 contentWidth: contentWidth ?? self.contentWidth,
282286 changeBarWidth: changeBarWidth,
......@@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
434438 guard let self, generation == self.rowsDecodeGeneration else {
435439 return
436440 }
437 self.rows = decodedRows
438 self.contentView.rows = decodedRows
439 self.hasAppliedInitialRowIndex = false
440 self.lastVisibleFileId = nil
441 self.emitDebug("rows-decoded", [
442 "rows": decodedRows.count,
443 "firstKind": decodedRows.first?.kind ?? "none",
444 ])
445 self.updateContentMetrics()
446 self.applyPendingScrollIfNeeded()
441 self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in
442 generation == self?.rowsDecodeGeneration
443 }, completion: { [weak self] in
444 guard let self, generation == self.rowsDecodeGeneration else { return }
445 self.rows = decodedRows
446 self.contentView.rows = decodedRows
447 self.hasAppliedInitialRowIndex = false
448 self.lastVisibleFileId = nil
449 self.emitDebug("rows-decoded", [
450 "rows": decodedRows.count,
451 "firstKind": decodedRows.first?.kind ?? "none",
452 ])
453 self.updateContentMetrics()
454 self.applyPendingScrollIfNeeded()
455 })
447456 }
448457 } catch {
449458 let message = error.localizedDescription
......@@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
652661
653662 private func updateContentMetrics() {
654663 let style = contentView.style
664 contentView.viewportWidth = bounds.width
655665 let height = max(bounds.height, contentView.contentHeight)
656666 let width = bounds.width
657667 scrollView.contentSize = CGSize(width: bounds.width, height: height)
......@@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
661671 width: max(width, 1),
662672 height: max(bounds.height, 1)
663673 )
664 contentView.viewportWidth = bounds.width
665674 contentView.verticalOffset = scrollView.contentOffset.y
666675 contentView.invalidateVisibleViewport()
667676 contentView.setNeedsDisplay()
......@@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
929938 headerPathOffsetsByFileId.removeAll()
930939 activePanFileId = nil
931940 activePanKind = nil
941 codeDecorationVersion += 1
932942 tokenAttributedStringsByRowId.removeAll()
933943 rebuildRowLayout()
934944 setNeedsDisplayForVisibleBounds()
......@@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
936946 }
937947 var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {
938948 didSet {
949 codeDecorationVersion += 1
939950 tokenAttributedStringsByRowId.removeAll()
940951 clampHorizontalOffsets()
941952 setNeedsDisplayForVisibleBounds()
......@@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
976987 }
977988 var style = ReviewDiffNativeStyle.resolve(nil) {
978989 didSet {
990 codeDecorationVersion += 1
979991 tokenAttributedStringsByRowId.removeAll()
980992 rebuildRowLayout()
981993 clampHorizontalOffsets()
......@@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
984996 }
985997 var viewportWidth: CGFloat = 0 {
986998 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 }
9871003 clampHorizontalOffsets()
9881004 setNeedsDisplayForVisibleBounds()
9891005 }
......@@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
9921008 var theme = ReviewDiffNativeTheme.resolve("light") {
9931009 didSet {
9941010 tokenColorsByHex.removeAll()
1011 codeDecorationVersion += 1
9951012 tokenAttributedStringsByRowId.removeAll()
9961013 setNeedsDisplayForVisibleBounds()
9971014 }
......@@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
10041021 private var tokenColorsByHex: [String: UIColor] = [:]
10051022 private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]
10061023 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
10071031 private var panStartHorizontalOffset: CGFloat = 0
10081032 private var activePanFileId: String?
10091033 private var activePanKind: ReviewDiffHorizontalPanKind?
......@@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
10291053 stickyWidth + style.codePadding
10301054 }
10311055
1056 /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines.
10321057 private func height(for row: ReviewDiffNativeRow) -> CGFloat {
10331058 if row.kind == "file" {
10341059 return style.fileHeaderHeight
......@@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
10451070 return style.rowHeight
10461071 }
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
10481083 func frameForRow(at index: Int) -> CGRect? {
10491084 guard rows.indices.contains(index), rowOffsets.indices.contains(index) else {
10501085 return nil
......@@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
10541089 x: 0,
10551090 y: rowOffsets[index],
10561091 width: max(viewportWidth, 1),
1057 height: height(for: rows[index])
1092 height: height(at: index)
10581093 )
10591094 }
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
10611135 private func rebuildRowLayout() {
10621136 var nextOffsets: [CGFloat] = []
10631137 var nextFileHeaderRowIndices: [Int] = []
10641138 nextOffsets.reserveCapacity(rows.count)
10651139 var maxColumnCountsByFileId: [String: Int] = [:]
1140 var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:]
10661141 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
10681154 for (index, row) in rows.enumerated() {
10691155 nextOffsets.append(offset)
10701156 if row.kind == "file" {
10711157 nextFileHeaderRowIndices.append(index)
10721158 }
1073 offset += height(for: row)
1159 var rowHeight = height(for: row)
10741160
10751161 let fileId = resolvedFileId(for: row)
10761162 switch row.kind {
10771163 case "line":
1078 maxColumnCountsByFileId[fileId] = max(
1079 maxColumnCountsByFileId[fileId] ?? 0,
1080 row.content?.count ?? 0
1081 )
1164 // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws.
1165 let columnCount = row.content?.utf16.count ?? 0
1166 maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount)
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 }
10821182 case "hunk":
10831183 maxColumnCountsByFileId[fileId] = max(
10841184 maxColumnCountsByFileId[fileId] ?? 0,
1085 row.text?.count ?? 0
1185 row.text?.utf16.count ?? 0
10861186 )
10871187 default:
1088 continue
1188 break
10891189 }
1190 offset += rowHeight
10901191 }
10911192
1092 let characterWidth = monospaceCharacterWidth(font: codeFont)
10931193 codeCharacterWidth = characterWidth
1194 codeWrapColumns = wrapColumns
1195 codeLayoutsByRowId = nextCodeLayouts
10941196 contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
10951197 let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
10961198 return max(0, min(style.contentWidth, measuredWidth))
......@@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
14981600 while lowerBound <= upperBound {
14991601 let midpoint = (lowerBound + upperBound) / 2
15001602 let rowStart = rowOffsets[midpoint]
1501 let rowEnd = rowStart + height(for: rows[midpoint])
1603 let rowEnd = rowStart + height(at: midpoint)
15021604
15031605 if absoluteY < rowStart {
15041606 upperBound = midpoint - 1
......@@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
15211623 var upperBound = rows.count
15221624 while lowerBound < upperBound {
15231625 let midpoint = (lowerBound + upperBound) / 2
1524 let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint])
1626 let rowEnd = rowOffsets[midpoint] + height(at: midpoint)
15251627
15261628 if rowEnd < absoluteY {
15271629 lowerBound = midpoint + 1
......@@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
16091711 let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {
16101712 return maxHeaderPathOffset(for: row)
16111713 }
1714 if codeWrapColumns != nil {
1715 return 0
1716 }
16121717
16131718 return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))
16141719 }
......@@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
17281833 var drawnRowCount = 0
17291834 for rowIndex in firstRowIndex...lastRowIndex {
17301835 let rowStart = rowOffsets[rowIndex]
1731 let rowHeight = height(for: rows[rowIndex])
1836 let rowHeight = height(at: rowIndex)
17321837 if rowHeight <= 0 {
17331838 continue
17341839 }
......@@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
17861891
17871892 private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {
17881893 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
17911896 switch row.kind {
17921897 case "file":
......@@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
21722277 let horizontalOffset = horizontalOffset(for: fileId)
21732278 let contentWidth = contentWidth(for: fileId)
21742279 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 )
21752286 rowBackground(for: change).setFill()
21762287 context.fill(rect)
21772288
21782289 if change == "add" {
21792290 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))
21812292 } else if change == "delete" {
21822293 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),
21842295 context: context
21852296 )
21862297 }
......@@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
21932304 "\(lineNumber)",
21942305 rect: CGRect(
21952306 x: style.changeBarWidth,
2196 y: centeredTextY(in: rect, font: lineNumberFont),
2307 y: centeredTextY(in: firstLineRect, font: lineNumberFont),
21972308 width: style.gutterWidth - style.codePadding,
21982309 height: lineNumberFont.lineHeight
21992310 ),
......@@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
22032314 }
22042315
22052316 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))
22072318 let codeTextRect = CGRect(
22082319 x: codeStartX - horizontalOffset,
2209 y: centeredTextY(in: rect, font: codeFont),
2320 y: centeredTextY(in: firstLineRect, font: codeFont),
22102321 width: contentWidth,
22112322 height: codeFont.lineHeight
22122323 )
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 )
22142352 if let tokens = tokensByRowId[row.id], !tokens.isEmpty {
2215 drawTokenText(
2353 let attributedText = tokenAttributedString(
22162354 rowId: row.id,
2217 tokens,
2218 rect: codeTextRect,
2355 tokens: tokens,
22192356 fallbackColor: theme.text,
22202357 font: codeFont
22212358 )
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 }
22222365 } 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 }
22242371 }
22252372 context.restoreGState()
22262373 }
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
22282396 private func drawSelectionOverlay(
22292397 _ row: ReviewDiffNativeRow,
22302398 rect: CGRect,
......@@ -2244,7 +2412,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
22442412
22452413 private func drawWordDiffRanges(
22462414 _ row: ReviewDiffNativeRow,
2247 rowRect: CGRect,
2415 lineStarts: [Int],
2416 firstLineRect: CGRect,
22482417 context: CGContext,
22492418 horizontalOffset: CGFloat
22502419 ) {
......@@ -2263,8 +2432,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
22632432 } else {
22642433 fillColor = theme.deleteBar.withAlphaComponent(0.28)
22652434 }
2266 let highlightHeight = max(4, min(rowRect.height - 4, codeFont.lineHeight))
2267 let highlightY = rowRect.midY - highlightHeight / 2
2435 let highlightHeight = max(4, min(firstLineRect.height - 4, codeFont.lineHeight))
2436 let highlightY = firstLineRect.midY - highlightHeight / 2
22682437
22692438 fillColor.setFill()
22702439 for range in ranges {
......@@ -2272,15 +2441,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
22722441 continue
22732442 }
22742443
2275 let startX = codeStartX - horizontalOffset + CGFloat(range.start) * codeCharacterWidth
2276 let width = max(2, CGFloat(range.end - range.start) * codeCharacterWidth)
2277 let highlightRect = CGRect(
2278 x: startX,
2279 y: highlightY,
2280 width: width,
2281 height: highlightHeight
2282 )
2283 UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()
2444 // A wrapped row splits the highlight at each visual line boundary.
2445 for (line, lineStart) in lineStarts.enumerated() {
2446 let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : Int.max
2447 let start = max(range.start, lineStart)
2448 let end = min(range.end, lineEnd)
2449 guard end > start else {
2450 continue
2451 }
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 }
22842460 }
22852461 }
22862462
......@@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
24482624 return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)
24492625 }
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
24672627 private func tokenAttributedString(
24682628 rowId: String,
24692629 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: {
110110 () => JSON.stringify(nativeReviewDiffTheme),
111111 [nativeReviewDiffTheme],
112112 );
113 // The card's height is sized from its row count, so its snippet stays unwrapped.
113114 const nativeStyleJson = useMemo(
114 () => JSON.stringify(nativeReviewDiffStyle),
115 () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }),
115116 [nativeReviewDiffStyle],
116117 );
117118 const nativeDiffHeight = useMemo(
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+6-1
......@@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string {
6363 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
6464}
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) {
6771 return {
72 wordWrap,
6873 rowHeight: codeSurface.rowHeight,
6974 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
7075 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 @@
1import { useCallback, useEffect, useRef, useState } from "react";
1import { useEffect, useRef, useState } from "react";
22
33import {
44 highlightNativeReviewDiffVisibleRows,
......@@ -8,10 +8,10 @@ import {
88import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface";
99import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes";
1010
11interface NativeReviewVisibleRange {
12 readonly firstRowIndex: number;
13 readonly lastRowIndex: number;
14}
11import {
12 createReviewDiffHighlightScheduler,
13 type NativeReviewVisibleRange,
14} from "./reviewDiffHighlightScheduler";
1515
1616function createEmptyTokenPatch(resetKey: string): string {
1717 return JSON.stringify({ resetKey, tokensByRowId: {} });
......@@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: {
4343}) {
4444 const { enabled, files, resetKey, rows, scheme } = input;
4545 const highlightedRowIdsRef = useRef<Set<string>>(new Set());
46 const visibleRangeRef = useRef<NativeReviewVisibleRange>({
46 const [visibleRange, setVisibleRange] = useState<NativeReviewVisibleRange>({
4747 firstRowIndex: 0,
4848 lastRowIndex: 80,
4949 });
5050 const visibleChunkIndexRef = useRef(0);
5151 const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey));
52 const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0);
52 const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange));
5353
5454 useEffect(() => {
55 scheduler.reset();
5556 highlightedRowIdsRef.current = new Set();
5657 visibleChunkIndexRef.current = 0;
57 visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 };
58 setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 });
5859 setTokensPatchJson(createEmptyTokenPatch(resetKey));
59 if (enabled && rows.length > 0) {
60 setVisibleHighlightRequest((request) => request + 1);
61 }
62 }, [enabled, resetKey, rows.length]);
60 return () => scheduler.cancel();
61 }, [enabled, resetKey, rows.length, scheduler]);
6362
6463 useEffect(() => {
6564 if (!enabled || rows.length === 0) {
......@@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: {
6766 }
6867
6968 const abortController = new AbortController();
70 const requestRange = visibleRangeRef.current;
69 const requestRange = visibleRange;
7170 const engine: NativeReviewDiffHighlightEngine = "native";
7271
7372 void (async () => {
......@@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: {
119118 })();
120119
121120 return () => abortController.abort();
122 }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]);
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 }, []);
121 }, [enabled, files, resetKey, rows, scheme, visibleRange]);
135122
136123 return {
137124 tokensPatchJson,
138 updateVisibleRange,
125 updateVisibleRange: scheduler.update,
139126 };
140127}
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts+2-2
......@@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): {
2121 );
2222 const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]);
2323 const nativeReviewDiffStyle = useMemo(
24 () => createNativeReviewDiffStyle(codeSurface),
25 [codeSurface],
24 () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak),
25 [appearance.codeWordBreak, codeSurface],
2626 );
2727
2828 return {