diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle index 22bb070b3b81..d360d1580f1d 100644 --- a/apps/mobile/modules/t3-review-diff/android/build.gradle +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -8,6 +8,10 @@ android { namespace 'expo.modules.t3reviewdiff' compileSdk rootProject.ext.compileSdkVersion + testOptions { + unitTests.includeAndroidResources = true + } + defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion @@ -16,4 +20,12 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16.1' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 6782e6894d99..80d0410643ff 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -9,6 +9,7 @@ import android.graphics.Path import android.graphics.RectF import android.graphics.Shader import android.graphics.Typeface +import android.text.TextPaint import kotlin.math.max import kotlin.math.min @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { textPaint.isUnderlineText = fontStyle and 4 != 0 } + var codeLayouts = CodeLayoutCache() + + /** Capture paint on the UI thread; the decode worker owns the new cache until publication. */ + fun prepareRows( + tokens: Map>, + style: DiffStyle, + width: Int + ): (List) -> CodeLayoutCache { + configureCodePaint(theme.text, 0, style) + val paint = TextPaint(textPaint) + val colors = theme + val cache = codeLayouts.copyForPreparation() + val availableWidth = ( + width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + ).toInt() + return { rows -> + cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) } + } + } + + fun codeWrapLayout( + rows: List, + tokens: Map>, + style: DiffStyle, + width: Int + ): CodeWrapLayout { + configureCodePaint(theme.text, 0, style) + val availableWidth = width - style.changeBarWidthPx - style.gutterWidthPx - + style.codePaddingPx * 2f + return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) + } + fun lineNumberColor(change: String): Int = when (change) { "add" -> theme.addText "delete" -> theme.deleteText @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } } + /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */ + @Suppress("LongParameterList") fun drawWordDiffRanges( canvas: Canvas, row: DiffRow, codeX: Float, top: Int, - bottom: Int + bottom: Int, + lines: CodeLines ) { + if (lines.nativeLayout != null) return if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return val color = if (row.change == "add") theme.addBar else theme.deleteBar backgroundPaint.color = withAlpha(color, 71) @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) val highlightTop = (top + bottom - highlightHeight) / 2f row.wordDiffRanges.forEach { range -> - val left = codeX + range.start * characterWidth - val right = max(left + 2f * density, codeX + range.end * characterWidth) - canvas.drawRoundRect( - RectF(left, highlightTop, right, highlightTop + highlightHeight), - 3f * density, - 3f * density, - backgroundPaint, - ) + // A wrapped row splits the highlight at each visual line boundary. + lines.starts.forEachIndexed { line, lineStart -> + val start = max(range.start, lineStart) + val end = min(range.end, lines.end(line, Int.MAX_VALUE)) + if (end <= start) return@forEachIndexed + val left = codeX + (start - lineStart) * characterWidth + val right = max(left + 2f * density, left + (end - start) * characterWidth) + val lineTop = highlightTop + line * lines.height + canvas.drawRoundRect( + RectF(left, lineTop, right, lineTop + highlightHeight), + 3f * density, + 3f * density, + backgroundPaint, + ) + } + } + } + + /** Draws a code row's text, or its syntax [tokens] when present, one visual line per start. */ + @Suppress("LongParameterList") + fun drawCode( + canvas: Canvas, + content: String, + tokens: List?, + codeX: Float, + baseline: Float, + style: DiffStyle, + lines: CodeLines + ) { + val nativeLayout = lines.nativeLayout + if (nativeLayout != null) { + canvas.save() + canvas.translate(codeX, baseline - nativeLayout.getLineBaseline(0)) + nativeLayout.draw(canvas) + canvas.restore() + return + } + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens + var line = 0 + var x = codeX + var column = 0 + runs.forEach { run -> + configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + var start = 0 + while (start < run.content.length) { + while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) { + line += 1 + x = codeX + } + val end = min(run.content.length, lines.end(line, Int.MAX_VALUE) - column) + val lineBaseline = baseline + line * lines.height + if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top && + lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom + ) { + canvas.drawText(run.content, start, end, x, lineBaseline, textPaint) + x += textPaint.measureText(run.content, start, end) + } + start = end + } + column += run.content.length } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt new file mode 100644 index 000000000000..dc77a8467033 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt @@ -0,0 +1,169 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.text.Layout +import android.text.SpannableString +import android.text.Spanned +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import kotlin.math.ceil +import kotlin.math.max + +/** Text layout is independent of comment heights and vertical row offsets. */ +internal class CodeLines( + val starts: IntArray, + val height: Int, + val nativeLayout: StaticLayout? = null +) { + fun end(line: Int, length: Int): Int = if (line + 1 < starts.size) starts[line + 1] else length + + fun firstHeight(base: Int): Int = max(base, nativeLayout?.getLineBottom(0) ?: 0) + + fun baseline(top: Int, bottom: Int, paint: Paint): Float = nativeLayout?.let { + top + (bottom - top - it.getLineBottom(0)) / 2f + it.getLineBaseline(0) + } ?: ((top + bottom - paint.fontMetrics.ascent - paint.fontMetrics.descent) / 2f) + + val extraHeight: Int + get() = nativeLayout?.let { it.height - it.getLineBottom(0) } ?: ((starts.size - 1) * height) +} + +internal class CodeWrapLayout( + val enabled: Boolean, + private val linesByRowId: Map +) { + fun lines(rowId: String): CodeLines = linesByRowId[rowId] ?: SINGLE_LINE + fun extraHeight(rowId: String): Int = lines(rowId).extraHeight + fun rowHeight(rowId: String, base: Int): Int = lines(rowId).let { + it.firstHeight(base) + + it.extraHeight + } + + companion object { + private val SINGLE_LINE = CodeLines(intArrayOf(0), 0) + val NONE = CodeWrapLayout(false, emptyMap()) + } +} + +/** ASCII is fixed-pitch; other text needs the same shaping for measurement and drawing. */ +internal fun createCodeLines(text: CharSequence, paint: TextPaint, width: Int): CodeLines { + val characterWidth = paint.measureText("M") + val lineHeight = ceil(paint.fontMetrics.run { descent - ascent }).toInt() + if (text.all { it in ' '..'~' }) { + val columns = max(1, (width / characterWidth).toInt()) + return CodeLines( + IntArray(max(1, (text.length + columns - 1) / columns)) { + it * columns + }, + lineHeight + ) + } + val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, max(1, width)) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setIncludePad(false) + .setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE) + .setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE) + .build() + return CodeLines(IntArray(layout.lineCount) { layout.getLineStart(it) }, lineHeight, layout) +} + +internal class CodeLayoutCache { + private data class Entry(val row: DiffRow, val tokens: List?, val lines: CodeLines) + private var entries = emptyMap() + private var previousStyle: DiffStyle? = null + private var previousTheme: DiffTheme? = null + private var previousWidth = 0 + + /** Entries are immutable; a worker can reuse them without changing the displayed cache. */ + fun copyForPreparation(): CodeLayoutCache = CodeLayoutCache().also { + it.entries = entries + it.previousStyle = previousStyle + it.previousTheme = previousTheme + it.previousWidth = previousWidth + } + + @Suppress("LongParameterList") + fun layout( + rows: List, + tokens: Map>, + paint: Paint, + style: DiffStyle, + theme: DiffTheme, + width: Int + ): CodeWrapLayout { + if (!style.wordWrap || width < paint.measureText("M")) { + entries = emptyMap() + return CodeWrapLayout.NONE + } + if (previousStyle != style || previousTheme != theme || previousWidth != width) { + entries = emptyMap() + previousStyle = style + previousTheme = theme + previousWidth = width + } + val next = HashMap() + val layouts = HashMap() + for (row in rows) { + if (row.kind != "line") continue + val rowTokens = tokens[row.id] + val cached = entries[row.id] + val entry = if (cached?.row == row && cached.tokens == rowTokens) { + cached + } else { + val text = styledCode(row, rowTokens, theme) + Entry(row, rowTokens, createCodeLines(text, TextPaint(paint), width)) + } + next[row.id] = entry + layouts[row.id] = entry.lines + } + entries = next + return CodeWrapLayout(true, layouts) + } + + private fun styledCode(row: DiffRow, tokens: List?, theme: DiffTheme): CharSequence { + // The ASCII path uses the existing token drawing and rounded highlight rectangles. + if (row.content.all { it in ' '..'~' }) return row.content + val text = SpannableString(row.content) + var offset = 0 + for (token in tokens.orEmpty()) { + val end = (offset + token.content.length).coerceAtMost(text.length) + if (end > offset) { + token.color?.let { + text.setSpan(ForegroundColorSpan(it), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val fontStyle = (if (token.fontStyle and 2 != 0) Typeface.BOLD else 0) or + (if (token.fontStyle and 1 != 0) Typeface.ITALIC else 0) + if (fontStyle != + 0 + ) { + text.setSpan(StyleSpan(fontStyle), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + if (token.fontStyle and 4 != + 0 + ) { + text.setSpan(UnderlineSpan(), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + offset = end + } + if (row.change == "add" || row.change == "delete") { + val bar = if (row.change == "add") theme.addBar else theme.deleteBar + val color = Color.argb(71, Color.red(bar), Color.green(bar), Color.blue(bar)) + for (range in row.wordDiffRanges) { + val start = range.start.coerceIn(0, text.length) + val end = range.end.coerceIn(start, text.length) + if (end > + start + ) { + text.setSpan(BackgroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + return text + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 97e9f696db90..37fee1cb3613 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont fun setRowsJson(value: String) { rowsDecodeGeneration += 1 val generation = rowsDecodeGeneration + val prepareLayout = canvasView.prepareRows() payloadDecodeExecutor.execute { val decodedRows = parseRows(value) + val codeLayouts = prepareLayout(decodedRows) post { if (generation != rowsDecodeGeneration) return@post + canvasView.useCodeLayouts(codeLayouts) rows = decodedRows lastVisibleFileId = null rebuildVisibleRows() @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( val end: Int ) -private data class DiffToken( +internal data class DiffToken( val content: String, val color: Int?, val fontStyle: Int @@ -543,6 +546,7 @@ internal data class DiffTheme( } internal data class DiffStyle( + val wordWrap: Boolean, val rowHeightPx: Float, val gutterWidthPx: Float, val codePaddingPx: Float, @@ -564,6 +568,7 @@ internal data class DiffStyle( ) { companion object { fun defaults(density: Float): DiffStyle = DiffStyle( + wordWrap = false, rowHeightPx = 20f * density, gutterWidthPx = 72f * density, codePaddingPx = 10f * density, @@ -587,6 +592,7 @@ internal data class DiffStyle( fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { val json = JSONObject(value) DiffStyle( + wordWrap = json.optBoolean("wordWrap", fallback.wordWrap), rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { }, ) private var rowOffsets = intArrayOf(0) + + private var codeWrap = CodeWrapLayout.NONE private var verticalOffset = 0 private var horizontalOffset = 0 private val headerPathOffsetsByFileId = mutableMapOf() @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { var tokensByRowId: Map> = emptyMap() set(value) { field = value + if (style.wordWrap) rebuildOffsets() invalidate() } var viewedFileIds: Set = emptySet() @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { set(value) { field = value drawing.theme = value + if (style.wordWrap) rebuildOffsets() invalidate() } var style: DiffStyle = DiffStyle.defaults(density) @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) + fun useCodeLayouts(layouts: CodeLayoutCache) { + drawing.codeLayouts = layouts + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { setMeasuredDimension( MeasureSpec.getSize(widthMeasureSpec), @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { super.onSizeChanged(width, height, oldWidth, oldHeight) + // Wrapped rows take their height from the width, so a new width is a new layout. + if (style.wordWrap && width != oldWidth) layoutRows() setVerticalOffset(verticalOffset) setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { fun horizontalOffset(): Int = horizontalOffset - fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + fun maxHorizontalOffset(): Int = + if (codeWrap.enabled) 0 else max(0, contentWidthPx - width) fun maxHorizontalOffset(target: HorizontalPanTarget): Int = if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun rebuildOffsets() { + layoutRows() + requestLayout() + invalidate() + } + + private fun layoutRows() { + codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) rowOffsets = IntArray(rows.size + 1) rows.forEachIndexed { index, row -> rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) } setVerticalOffset(verticalOffset) + setHorizontalOffset(horizontalOffset) clampHeaderPathOffsets() - requestLayout() - invalidate() } private fun rowHeight(row: DiffRow): Int = when (row.kind) { @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { } else { (124 * density).toInt() } + "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) else -> style.rowHeightPx.toInt() }.coerceAtLeast(1) @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { ) } - val tokens = tokensByRowId[row.id] + // Wrapped rows keep the line number and first code line in the first row-height band. + val lines = codeWrap.lines(row.id) + val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt()) drawScrollableCode(canvas, top, bottom) { codeX -> drawing.configureCodePaint(theme.text, 0, style) - drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) - if (tokens.isNullOrEmpty()) { - canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) - } else { - var x = codeX - tokens.forEach { token -> - drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) - canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) - x += textPaint.measureText(token.content) - } - } + drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines) + val baseline = lines.baseline(top, firstLineBottom, textPaint) + drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines) } - drawLineNumber(canvas, row, top, bottom) + drawLineNumber(canvas, row, top, firstLineBottom) } private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { diff --git a/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt new file mode 100644 index 000000000000..1f4234b7175f --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt @@ -0,0 +1,150 @@ +package expo.modules.t3reviewdiff + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.text.Spanned +import android.text.TextPaint +import android.text.style.BackgroundColorSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ReviewDiffCodeLayoutTest { + private val paint = TextPaint().apply { + color = Color.WHITE + textSize = 24f + typeface = Typeface.MONOSPACE + } + + @Test + fun unicodeAndTabsFitWithoutSplittingClusters() { + val fixtures = listOf("漢字表示", "e\u0301", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue ") + for (fixture in fixtures) { + val text = fixture.repeat(40) + for (width in listOf(180, 280, 420)) { + val layout = requireNotNull(createCodeLines(text, paint, width).nativeLayout) + assertInkFits(layout, width, fixture) + assertLinesFit(layout, fixture, width) + } + } + } + + private fun assertLinesFit(layout: android.text.StaticLayout, fixture: String, width: Int) { + val text = layout.text + for (line in 0 until layout.lineCount) { + if (!fixture.contains('\t')) { + assertTrue("$fixture line $line at $width", layout.getLineMax(line) <= width + 1) + } + val start = layout.getLineStart(line) + assertTrue(start == 0 || !Character.isLowSurrogate(text[start])) + if (fixture == "👨‍👩‍👧‍👦" || fixture == "e\u0301") { + assertEquals(0, start % fixture.length) + } + } + } + + private fun assertInkFits(layout: android.text.StaticLayout, width: Int, fixture: String) { + val bitmap = Bitmap.createBitmap(width + 40, layout.height, Bitmap.Config.ARGB_8888) + layout.draw(Canvas(bitmap)) + for (x in width + 1 until bitmap.width) { + for (y in 0 until bitmap.height) { + assertEquals("$fixture ink outside width $width", 0, Color.alpha(bitmap.getPixel(x, y))) + } + } + bitmap.recycle() + } + + @Test + fun asciiSegmentsCoverTheWholeLineAndFit() { + val text = "const value = 123; ".repeat(100) + val lines = createCodeLines(text, paint, 280) + val pieces = lines.starts.indices.map { + text.substring(lines.starts[it], lines.end(it, text.length)) + } + assertEquals(text, pieces.joinToString("")) + assertTrue(pieces.all { paint.measureText(it) <= 280 }) + } + + @Test + fun changingCommentHeightReusesCodeButWidthAndContentInvalidateIt() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(100)) + val comment = row.copy(kind = "comment", id = "comment", content = "", commentText = "Before") + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val first = cache.layout( + listOf(row, comment), + emptyMap(), + paint, + style, + theme, + 280 + ).lines(row.id) + val second = cache.layout( + listOf(row, comment.copy(commentText = "After")), + emptyMap(), + paint, + style, + theme, + 280, + ).lines(row.id) + assertSame(first, second) + val narrow = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + assertNotSame(first, narrow) + assertTrue(narrow.extraHeight > first.extraHeight) + val edited = cache.layout( + listOf(row.copy(content = "短い")), + emptyMap(), + paint, + style, + theme, + 180 + ).lines(row.id) + assertTrue(edited.extraHeight < narrow.extraHeight) + assertEquals( + 0, + cache.layout( + listOf(row), + emptyMap(), + paint, + style.copy(wordWrap = false), + theme, + 180 + ).extraHeight(row.id) + ) + } + + @Test + fun highlightsUseNativeTextRangesAndSurviveSyntaxArrival() { + val cache = CodeLayoutCache() + val row = row("漢字".repeat(30)).copy(wordDiffRanges = listOf(DiffWordDiffRange(3, 21))) + val style = DiffStyle.defaults(1f).copy(wordWrap = true) + val theme = DiffTheme.fallback("light") + val initial = cache.layout(listOf(row), emptyMap(), paint, style, theme, 180).lines(row.id) + val tokens = mapOf(row.id to listOf(DiffToken(row.content, 0xff008800.toInt(), 2))) + val highlighted = cache.layout(listOf(row), tokens, paint, style, theme, 180).lines(row.id) + assertNotSame(initial, highlighted) + val text = requireNotNull(highlighted.nativeLayout).text as Spanned + val span = text.getSpans(0, text.length, BackgroundColorSpan::class.java).single() + assertEquals(3, text.getSpanStart(span)) + assertEquals(21, text.getSpanEnd(span)) + } + + private fun row(content: String) = DiffRow( + kind = "line", id = "line", fileId = "file", filePath = "test.ts", previousPath = null, + changeType = "modified", additions = 1, deletions = 0, text = "", content = content, + change = "add", oldLineNumber = null, newLineNumber = 1, wordDiffRanges = emptyList(), + commentText = "", commentRangeLabel = "", commentSectionTitle = "", + ) +} diff --git a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift new file mode 100644 index 000000000000..1d8b3e8ea8d9 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift @@ -0,0 +1,123 @@ +import UIKit + +/// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. +final class ReviewDiffCodeLayout: NSObject { + // Measurement reuses one engine; only recently drawn rows retain a full TextKit layout. + private static var measurer: ReviewDiffTextLayout { + let key = "T3ReviewDiff.textMeasurer" + if let layout = Thread.current.threadDictionary[key] as? ReviewDiffTextLayout { return layout } + let layout = ReviewDiffTextLayout() + Thread.current.threadDictionary[key] = layout + return layout + } + private static let drawnLayouts: NSCache = { + let cache = NSCache() + cache.countLimit = 128 + return cache + }() + let text: String + let starts: [Int] + let lineHeight: CGFloat + let firstLineHeight: CGFloat + let extraHeight: CGFloat + private let font: UIFont + private let width: CGFloat + private let characterWidth: CGFloat + let usesNativeLayout: Bool + + init(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + self.text = text + self.font = font + self.width = width + self.characterWidth = characterWidth + lineHeight = ceil(font.lineHeight) + if text.utf8.allSatisfy({ $0 >= 32 && $0 <= 126 }) { + let columns = max(1, Int(width / characterWidth)) + starts = Array(stride(from: 0, to: max(1, text.utf8.count), by: columns)) + firstLineHeight = font.lineHeight + extraHeight = CGFloat(starts.count - 1) * lineHeight + usesNativeLayout = false + } else { + let layout = Self.measurer + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + let manager = layout.manager + let container = layout.container + usesNativeLayout = true + starts = [0] + firstLineHeight = manager.numberOfGlyphs > 0 + ? manager.lineFragmentRect(forGlyphAt: 0, effectiveRange: nil).height : font.lineHeight + extraHeight = max(0, manager.usedRect(for: container).height - firstLineHeight) + } + } + + private func nativeLayout() -> ReviewDiffTextLayout { + if let cached = Self.drawnLayouts.object(forKey: self) { return cached } + let layout = ReviewDiffTextLayout() + layout.configure(text: text, font: font, width: width, characterWidth: characterWidth) + Self.drawnLayouts.setObject(layout, forKey: self) + return layout + } + + /// Only colors change when syntax tokens arrive; the measured text and font stay intact. + func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + guard layout.decorationVersion != version else { return } + let storage = layout.storage + let fullRange = NSRange(location: 0, length: storage.length) + storage.beginEditing() + storage.removeAttribute(.foregroundColor, range: fullRange) + storage.removeAttribute(.backgroundColor, range: fullRange) + text.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: text.length)) { value, range, _ in + let intersection = NSIntersectionRange(range, fullRange) + if let value, intersection.length > 0 { + storage.addAttribute(.foregroundColor, value: value, range: intersection) + } + } + for range in highlights { + let intersection = NSIntersectionRange(range, fullRange) + if intersection.length > 0 { + storage.addAttribute(.backgroundColor, value: color, range: intersection) + } + } + storage.endEditing() + layout.decorationVersion = version + } + + func draw(at origin: CGPoint, clip: CGRect) { + guard usesNativeLayout else { return } + let layout = nativeLayout() + let manager = layout.manager + let container = layout.container + let visible = clip.offsetBy(dx: -origin.x, dy: -origin.y) + let range = manager.glyphRange(forBoundingRect: visible, in: container) + manager.drawBackground(forGlyphRange: range, at: origin) + manager.drawGlyphs(forGlyphRange: range, at: origin) + } +} + +private final class ReviewDiffTextLayout { + let storage = NSTextStorage() + let manager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + var decorationVersion = -1 + + init() { + container.lineFragmentPadding = 0 + container.lineBreakMode = .byCharWrapping + manager.addTextContainer(container) + storage.addLayoutManager(manager) + } + + func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byCharWrapping + paragraph.tabStops = [] + paragraph.defaultTabInterval = characterWidth * 4 + container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude) + storage.setAttributedString(NSAttributedString(string: text, attributes: [ + .font: font, .ligature: 0, .paragraphStyle: paragraph, + ])) + manager.ensureLayout(for: container) + } +} diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 74111988f150..e2400e0a9393 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme { } private struct ReviewDiffNativeStylePayload: Decodable { + let wordWrap: Bool? let rowHeight: Double? let contentWidth: Double? let changeBarWidth: Double? @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { } private struct ReviewDiffNativeStyle { + let wordWrap: Bool let rowHeight: CGFloat let contentWidth: CGFloat let changeBarWidth: CGFloat @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: payload?.wordWrap ?? false, rowHeight: metric(payload?.rowHeight, fallback: 24), contentWidth: metric(payload?.contentWidth, fallback: 2800), changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { ReviewDiffNativeStyle( + wordWrap: wordWrap, rowHeight: rowHeight ?? self.rowHeight, contentWidth: contentWidth ?? self.contentWidth, changeBarWidth: changeBarWidth, @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { guard let self, generation == self.rowsDecodeGeneration else { return } - self.rows = decodedRows - self.contentView.rows = decodedRows - self.hasAppliedInitialRowIndex = false - self.lastVisibleFileId = nil - self.emitDebug("rows-decoded", [ - "rows": decodedRows.count, - "firstKind": decodedRows.first?.kind ?? "none", - ]) - self.updateContentMetrics() - self.applyPendingScrollIfNeeded() + self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in + generation == self?.rowsDecodeGeneration + }, completion: { [weak self] in + guard let self, generation == self.rowsDecodeGeneration else { return } + self.rows = decodedRows + self.contentView.rows = decodedRows + self.hasAppliedInitialRowIndex = false + self.lastVisibleFileId = nil + self.emitDebug("rows-decoded", [ + "rows": decodedRows.count, + "firstKind": decodedRows.first?.kind ?? "none", + ]) + self.updateContentMetrics() + self.applyPendingScrollIfNeeded() + }) } } catch { let message = error.localizedDescription @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { private func updateContentMetrics() { let style = contentView.style + contentView.viewportWidth = bounds.width let height = max(bounds.height, contentView.contentHeight) let width = bounds.width scrollView.contentSize = CGSize(width: bounds.width, height: height) @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { width: max(width, 1), height: max(bounds.height, 1) ) - contentView.viewportWidth = bounds.width contentView.verticalOffset = scrollView.contentOffset.y contentView.invalidateVisibleViewport() contentView.setNeedsDisplay() @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { headerPathOffsetsByFileId.removeAll() activePanFileId = nil activePanKind = nil + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() setNeedsDisplayForVisibleBounds() @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var style = ReviewDiffNativeStyle.resolve(nil) { didSet { + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() rebuildRowLayout() clampHorizontalOffsets() @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } var viewportWidth: CGFloat = 0 { didSet { + // Wrapped rows take their height from the width, so a new width is a new layout. + if style.wordWrap, viewportWidth != oldValue { + rebuildRowLayout() + } clampHorizontalOffsets() setNeedsDisplayForVisibleBounds() } @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var theme = ReviewDiffNativeTheme.resolve("light") { didSet { tokenColorsByHex.removeAll() + codeDecorationVersion += 1 tokenAttributedStringsByRowId.removeAll() setNeedsDisplayForVisibleBounds() } @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private var tokenColorsByHex: [String: UIColor] = [:] private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] private var codeCharacterWidth: CGFloat = 8 + /// Columns per visual line while word wrap is on; nil while code rows pan horizontally. + private var codeWrapColumns: Int? + /// Text geometry survives comment height changes; width, font, and content invalidate it. + private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] + private var codeLayoutWidth: CGFloat = 0 + private var codeLayoutFont: UIFont? + private var codeDecorationVersion = 0 private var panStartHorizontalOffset: CGFloat = 0 private var activePanFileId: String? private var activePanKind: ReviewDiffHorizontalPanKind? @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { stickyWidth + style.codePadding } + /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. private func height(for row: ReviewDiffNativeRow) -> CGFloat { if row.kind == "file" { return style.fileHeaderHeight @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return style.rowHeight } + /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. + private func height(at index: Int) -> CGFloat { + let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight + return nextOffset - rowOffsets[index] + } + + private var codeWrapLineHeight: CGFloat { + ceil(codeFont.lineHeight) + } + func frameForRow(at index: Int) -> CGRect? { guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { return nil @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { x: 0, y: rowOffsets[index], width: max(viewportWidth, 1), - height: height(for: rows[index]) + height: height(at: index) ) } + /// Shape new content on the existing decode worker before publishing rows to the UI. + func prepareRows( + _ rows: [ReviewDiffNativeRow], + on queue: DispatchQueue, + isCurrent: @escaping () -> Bool, + completion: @escaping () -> Void + ) { + let font = codeFont + let width = viewportWidth - codeStartX - style.codePadding + let characterWidth = monospaceCharacterWidth(font: font) + guard style.wordWrap, width >= characterWidth, characterWidth > 0 else { + completion() + return + } + let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] + queue.async { [weak self] in + var layouts: [String: ReviewDiffCodeLayout] = [:] + for row in rows where row.kind == "line" { + guard let text = row.content else { continue } + if let previous = cached[row.id], previous.text == text { + layouts[row.id] = previous + } else { + layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + } + } + DispatchQueue.main.async { [weak self] in + guard let self, isCurrent() else { return } + if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width { + self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) + return + } + self.codeLayoutWidth = width + self.codeLayoutFont = font + self.codeLayoutsByRowId = layouts + completion() + } + } + } + private func rebuildRowLayout() { var nextOffsets: [CGFloat] = [] var nextFileHeaderRowIndices: [Int] = [] nextOffsets.reserveCapacity(rows.count) var maxColumnCountsByFileId: [String: Int] = [:] + var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] var offset: CGFloat = 0 + let font = codeFont + let characterWidth = monospaceCharacterWidth(font: font) + let wrapAvailableWidth = viewportWidth - codeStartX - style.codePadding + let wrapColumns = style.wordWrap && characterWidth > 0 && wrapAvailableWidth >= characterWidth + ? Int(wrapAvailableWidth / characterWidth) + : nil + if codeLayoutWidth != wrapAvailableWidth || codeLayoutFont != font { + codeLayoutsByRowId.removeAll() + codeLayoutWidth = wrapAvailableWidth + codeLayoutFont = font + } for (index, row) in rows.enumerated() { nextOffsets.append(offset) if row.kind == "file" { nextFileHeaderRowIndices.append(index) } - offset += height(for: row) + var rowHeight = height(for: row) let fileId = resolvedFileId(for: row) switch row.kind { case "line": - maxColumnCountsByFileId[fileId] = max( - maxColumnCountsByFileId[fileId] ?? 0, - row.content?.count ?? 0 - ) + // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. + let columnCount = row.content?.utf16.count ?? 0 + maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount) + if wrapColumns != nil, let content = row.content { + let cached = codeLayoutsByRowId[row.id] + let layout: ReviewDiffCodeLayout + if let cached, cached.text == content { + layout = cached + } else { + layout = ReviewDiffCodeLayout( + text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth + ) + } + nextCodeLayouts[row.id] = layout + if rowHeight > 0 { + rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight + } + } case "hunk": maxColumnCountsByFileId[fileId] = max( maxColumnCountsByFileId[fileId] ?? 0, - row.text?.count ?? 0 + row.text?.utf16.count ?? 0 ) default: - continue + break } + offset += rowHeight } - let characterWidth = monospaceCharacterWidth(font: codeFont) codeCharacterWidth = characterWidth + codeWrapColumns = wrapColumns + codeLayoutsByRowId = nextCodeLayouts contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 return max(0, min(style.contentWidth, measuredWidth)) @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { while lowerBound <= upperBound { let midpoint = (lowerBound + upperBound) / 2 let rowStart = rowOffsets[midpoint] - let rowEnd = rowStart + height(for: rows[midpoint]) + let rowEnd = rowStart + height(at: midpoint) if absoluteY < rowStart { upperBound = midpoint - 1 @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var upperBound = rows.count while lowerBound < upperBound { let midpoint = (lowerBound + upperBound) / 2 - let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint]) + let rowEnd = rowOffsets[midpoint] + height(at: midpoint) if rowEnd < absoluteY { lowerBound = midpoint + 1 @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { return maxHeaderPathOffset(for: row) } + if codeWrapColumns != nil { + return 0 + } return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) } @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var drawnRowCount = 0 for rowIndex in firstRowIndex...lastRowIndex { let rowStart = rowOffsets[rowIndex] - let rowHeight = height(for: rows[rowIndex]) + let rowHeight = height(at: rowIndex) if rowHeight <= 0 { continue } @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { let rowY = rowOffsets[rowIndex] - verticalOffset - let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row)) + let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(at: rowIndex)) switch row.kind { case "file": @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let horizontalOffset = horizontalOffset(for: fileId) let contentWidth = contentWidth(for: fileId) let change = row.change ?? "context" + // Wrapped rows keep the line number and first code line in the first row-height band. + let layout = codeLayoutsByRowId[row.id] + let firstLineRect = CGRect( + x: rect.minX, y: rect.minY, width: rect.width, + height: max(style.rowHeight, layout?.firstLineHeight ?? 0) + ) rowBackground(for: change).setFill() context.fill(rect) if change == "add" { theme.addBar.setFill() - context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) + context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) } else if change == "delete" { drawDeleteStripes( - rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), + rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), context: context ) } @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { "\(lineNumber)", rect: CGRect( x: style.changeBarWidth, - y: centeredTextY(in: rect, font: lineNumberFont), + y: centeredTextY(in: firstLineRect, font: lineNumberFont), width: style.gutterWidth - style.codePadding, height: lineNumberFont.lineHeight ), @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } context.saveGState() - context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) + context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) let codeTextRect = CGRect( x: codeStartX - horizontalOffset, - y: centeredTextY(in: rect, font: codeFont), + y: centeredTextY(in: firstLineRect, font: codeFont), width: contentWidth, height: codeFont.lineHeight ) - drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset) + if let layout, layout.usesNativeLayout { + let text = tokensByRowId[row.id].map { + tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont) + } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text]) + let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : [] + layout.decorate( + text: text, + highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map { + NSRange(location: $0.start, length: $0.end - $0.start) + }, + color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), + version: codeDecorationVersion + ) + layout.draw( + at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)), + clip: context.boundingBoxOfClipPath + ) + context.restoreGState() + return + } + let lineStarts = layout?.starts ?? [0] + drawWordDiffRanges( + row, + lineStarts: lineStarts, + firstLineRect: firstLineRect, + context: context, + horizontalOffset: horizontalOffset + ) if let tokens = tokensByRowId[row.id], !tokens.isEmpty { - drawTokenText( + let attributedText = tokenAttributedString( rowId: row.id, - tokens, - rect: codeTextRect, + tokens: tokens, fallbackColor: theme.text, font: codeFont ) + drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == attributedText.length + ? attributedText + : attributedText.attributedSubstring(from: range) + segment.draw(in: lineRect) + } } else { - drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) + let content = (row.content ?? "") as NSString + drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in + let segment = range.length == content.length ? content as String : content.substring(with: range) + drawText(segment, rect: lineRect, color: theme.text, font: codeFont) + } } context.restoreGState() } + /// Draws the segment starting at each of the row's line starts on its own visual line. + private func drawCodeLines( + length: Int, + lineStarts: [Int], + firstLineRect: CGRect, + draw: (NSRange, CGRect) -> Void + ) { + var lineRect = firstLineRect + let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds + let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight))) + let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight))) + guard first < last else { return } + lineRect.origin.y += CGFloat(first) * codeWrapLineHeight + for line in first.. start else { + continue + } + let highlightRect = CGRect( + x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth, + y: highlightY + CGFloat(line) * codeWrapLineHeight, + width: max(2, CGFloat(end - start) * codeCharacterWidth), + height: highlightHeight + ) + UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() + } } } @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) } - private func drawTokenText( - rowId: String, - _ tokens: [ReviewDiffNativeToken], - rect: CGRect, - fallbackColor: UIColor, - font: UIFont - ) { - let attributedText = tokenAttributedString( - rowId: rowId, - tokens: tokens, - fallbackColor: fallbackColor, - font: font - ) - attributedText.draw(in: rect) - } - private func tokenAttributedString( rowId: String, tokens: [ReviewDiffNativeToken], diff --git a/apps/mobile/modules/t3-review-diff/tests/ios/main.swift b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift new file mode 100644 index 000000000000..baeeaae77d9a --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/tests/ios/main.swift @@ -0,0 +1,60 @@ +import UIKit + +func check(_ passed: Bool, _ message: String = "Failed layout check") { + if !passed { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) + } +} + +// Runs the production layout against UIKit through Mac Catalyst, without launching an app. +let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) +let characterWidth = ("M" as NSString).size(withAttributes: [.font: font]).width +let fixtures = ["漢字表示", "e\u{301}", "👨‍👩‍👧‍👦", "مرحبا بالعالم ", "\tvalue "] +var cases = 0 +for fixture in fixtures { + let text = String(repeating: fixture, count: 40) + var previousHeight = CGFloat.greatestFiniteMagnitude + for width: CGFloat in [180, 280, 420] { + let layout = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) + let height = layout.firstLineHeight + layout.extraHeight + check(height <= previousHeight, "Wider text must not require more height") + previousHeight = height + let fullRange = NSRange(location: 0, length: text.utf16.count) + let attributed = NSAttributedString(string: text, attributes: [.foregroundColor: UIColor.black]) + layout.decorate(text: attributed, highlights: [], color: .clear, version: 0) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + format.opaque = false + format.preferredRange = .standard + let size = CGSize(width: width + 40, height: ceil(height)) + let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in + layout.draw(at: .zero, clip: CGRect(origin: .zero, size: size)) + } + let bitmap = image.cgImage! + let data = bitmap.dataProvider!.data! + let bytes = CFDataGetBytePtr(data)! + // Render without a viewport clip so an overflowing glyph cannot hide behind clipping. + for y in 0.. JSON.stringify(nativeReviewDiffTheme), [nativeReviewDiffTheme], ); + // The card's height is sized from its row count, so its snippet stays unwrapped. const nativeStyleJson = useMemo( - () => JSON.stringify(nativeReviewDiffStyle), + () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), [nativeReviewDiffStyle], ); const nativeDiffHeight = useMemo( diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index 8bea04c524dd..3056cc2136ca 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string { return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } -export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { +/** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */ +export function createNativeReviewDiffStyle( + codeSurface: ResolvedMobileCodeSurface, + wordWrap: boolean, +) { return { + wordWrap, rowHeight: codeSurface.rowHeight, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts new file mode 100644 index 000000000000..3792e49f54c7 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createReviewDiffHighlightScheduler } from "./reviewDiffHighlightScheduler"; + +describe("review diff highlighting while scrolling", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("keeps requesting new rows during gradual scrolling through a large diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let firstRowIndex = 1; firstRowIndex <= 1_674; firstRowIndex++) { + scheduler.update({ firstRowIndex, lastRowIndex: firstRowIndex + 80 }); + vi.advanceTimersByTime(16); + } + expect(request.mock.calls.length).toBeGreaterThan(100); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenLastCalledWith({ firstRowIndex: 1_674, lastRowIndex: 1_754 }); + }); + + it("highlights the settled viewport even below the movement threshold", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 2, lastRowIndex: 82 }); + vi.advanceTimersByTime(100); + scheduler.update({ firstRowIndex: 3, lastRowIndex: 83 }); + vi.advanceTimersByTime(100); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(50); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 3, lastRowIndex: 83 }); + }); + + it("does not let repeated draw events starve the settled refresh", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + for (let i = 0; i < 10; i++) { + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + vi.advanceTimersByTime(30); + } + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); + + it("requests large jumps and reverse scrolling immediately without stale timers", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + scheduler.update({ firstRowIndex: 0, lastRowIndex: 80 }); + vi.runAllTimers(); + expect(request.mock.calls).toEqual([ + [{ firstRowIndex: 1_000, lastRowIndex: 1_080 }], + [{ firstRowIndex: 0, lastRowIndex: 80 }], + ]); + }); + + it("cancels pending work on disposal and resets the range for a new diff", () => { + const request = vi.fn(); + const scheduler = createReviewDiffHighlightScheduler(request); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + scheduler.cancel(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1_000, lastRowIndex: 1_080 }); + request.mockClear(); + scheduler.update({ firstRowIndex: 1_001, lastRowIndex: 1_081 }); + scheduler.reset(); + vi.runAllTimers(); + expect(request).not.toHaveBeenCalled(); + scheduler.update({ firstRowIndex: 1, lastRowIndex: 81 }); + expect(request).not.toHaveBeenCalled(); + vi.advanceTimersByTime(150); + expect(request).toHaveBeenCalledExactlyOnceWith({ firstRowIndex: 1, lastRowIndex: 81 }); + }); +}); diff --git a/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts new file mode 100644 index 000000000000..4cededa52954 --- /dev/null +++ b/apps/mobile/src/features/review/reviewDiffHighlightScheduler.ts @@ -0,0 +1,51 @@ +export interface NativeReviewVisibleRange { + readonly firstRowIndex: number; + readonly lastRowIndex: number; +} + +export function createReviewDiffHighlightScheduler( + request: (range: NativeReviewVisibleRange) => void, +) { + let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 }; + let visibleRange = requestedRange; + let timer: ReturnType | undefined; + + const cancel = () => { + clearTimeout(timer); + timer = undefined; + }; + const flush = () => { + cancel(); + requestedRange = visibleRange; + request(visibleRange); + }; + + return { + update(nextRange: NativeReviewVisibleRange) { + if ( + nextRange.firstRowIndex === visibleRange.firstRowIndex && + nextRange.lastRowIndex === visibleRange.lastRowIndex + ) { + return; + } + visibleRange = nextRange; + cancel(); + // Accumulate small scroll events relative to the last request, not each other. + const movedRows = + Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) + + Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex); + if (movedRows >= 20) { + flush(); + } else if (movedRows > 0) { + // Cover the final viewport even when scrolling stops below the threshold. + timer = setTimeout(flush, 150); + } + }, + reset() { + cancel(); + requestedRange = { firstRowIndex: 0, lastRowIndex: 80 }; + visibleRange = requestedRange; + }, + cancel, + }; +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts index 35f06c263666..61a205f9d917 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { highlightNativeReviewDiffVisibleRows, @@ -8,10 +8,10 @@ import { import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; -interface NativeReviewVisibleRange { - readonly firstRowIndex: number; - readonly lastRowIndex: number; -} +import { + createReviewDiffHighlightScheduler, + type NativeReviewVisibleRange, +} from "./reviewDiffHighlightScheduler"; function createEmptyTokenPatch(resetKey: string): string { return JSON.stringify({ resetKey, tokensByRowId: {} }); @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { }) { const { enabled, files, resetKey, rows, scheme } = input; const highlightedRowIdsRef = useRef>(new Set()); - const visibleRangeRef = useRef({ + const [visibleRange, setVisibleRange] = useState({ firstRowIndex: 0, lastRowIndex: 80, }); const visibleChunkIndexRef = useRef(0); const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); - const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); + const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); useEffect(() => { + scheduler.reset(); highlightedRowIdsRef.current = new Set(); visibleChunkIndexRef.current = 0; - visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; + setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); setTokensPatchJson(createEmptyTokenPatch(resetKey)); - if (enabled && rows.length > 0) { - setVisibleHighlightRequest((request) => request + 1); - } - }, [enabled, resetKey, rows.length]); + return () => scheduler.cancel(); + }, [enabled, resetKey, rows.length, scheduler]); useEffect(() => { if (!enabled || rows.length === 0) { @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { } const abortController = new AbortController(); - const requestRange = visibleRangeRef.current; + const requestRange = visibleRange; const engine: NativeReviewDiffHighlightEngine = "native"; void (async () => { @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { })(); return () => abortController.abort(); - }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); - - const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { - const previousRange = visibleRangeRef.current; - const movedRows = - Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + - Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); - - visibleRangeRef.current = nextRange; - if (movedRows >= 20) { - setVisibleHighlightRequest((request) => request + 1); - } - }, []); + }, [enabled, files, resetKey, rows, scheme, visibleRange]); return { tokensPatchJson, - updateVisibleRange, + updateVisibleRange: scheduler.update, }; } diff --git a/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts b/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts index 62760f1e43fa..5bd7bb469827 100644 --- a/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts +++ b/apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts @@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): { ); const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); const nativeReviewDiffStyle = useMemo( - () => createNativeReviewDiffStyle(codeSurface), - [codeSurface], + () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak), + [appearance.codeWordBreak, codeSurface], ); return {