| author | |
| committer | |
| log | f6cc6bc7e9cbe1feeb7811ea92d519ad77c76596 |
| tree | cf3a8db3a32c51721419c8e6e6b93b1a70c3c664 |
| parent | 45e06f48a03354b600782259010ae6222241aff0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>15 files changed, 1032 insertions(+), 119 deletions(-)
apps/mobile/modules/t3-review-diff/android/build.gradle+12| ... | ... | @@ -8,6 +8,10 @@ android { |
| 8 | 8 | namespace 'expo.modules.t3reviewdiff' |
| 9 | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | |
| 11 | testOptions { | |
| 12 | unitTests.includeAndroidResources = true | |
| 13 | } | |
| 14 | ||
| 11 | 15 | defaultConfig { |
| 12 | 16 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | 17 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | ... | @@ -16,4 +20,12 @@ android { |
| 16 | 20 | |
| 17 | 21 | dependencies { |
| 18 | 22 | implementation project(':expo-modules-core') |
| 23 | testImplementation 'junit:junit:4.13.2' | |
| 24 | testImplementation 'org.robolectric:robolectric:4.16.1' | |
| 25 | } | |
| 26 | ||
| 27 | tasks.withType(Test).configureEach { | |
| 28 | javaLauncher = javaToolchains.launcherFor { | |
| 29 | languageVersion = JavaLanguageVersion.of(21) | |
| 30 | } | |
| 19 | 31 | } |
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt+99-9| ... | ... | @@ -9,6 +9,7 @@ import android.graphics.Path |
| 9 | 9 | import android.graphics.RectF |
| 10 | 10 | import android.graphics.Shader |
| 11 | 11 | import android.graphics.Typeface |
| 12 | import android.text.TextPaint | |
| 12 | 13 | import kotlin.math.max |
| 13 | 14 | import kotlin.math.min |
| 14 | 15 | |
| ... | ... | @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { |
| 177 | 178 | textPaint.isUnderlineText = fontStyle and 4 != 0 |
| 178 | 179 | } |
| 179 | 180 | |
| 181 | var codeLayouts = CodeLayoutCache() | |
| 182 | ||
| 183 | /** Capture paint on the UI thread; the decode worker owns the new cache until publication. */ | |
| 184 | fun prepareRows( | |
| 185 | tokens: Map<String, List<DiffToken>>, | |
| 186 | style: DiffStyle, | |
| 187 | width: Int | |
| 188 | ): (List<DiffRow>) -> CodeLayoutCache { | |
| 189 | configureCodePaint(theme.text, 0, style) | |
| 190 | val paint = TextPaint(textPaint) | |
| 191 | val colors = theme | |
| 192 | val cache = codeLayouts.copyForPreparation() | |
| 193 | val availableWidth = ( | |
| 194 | width - style.changeBarWidthPx - style.gutterWidthPx - | |
| 195 | style.codePaddingPx * 2f | |
| 196 | ).toInt() | |
| 197 | return { rows -> | |
| 198 | cache.apply { layout(rows, tokens, paint, style, colors, availableWidth) } | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | fun codeWrapLayout( | |
| 203 | rows: List<DiffRow>, | |
| 204 | tokens: Map<String, List<DiffToken>>, | |
| 205 | style: DiffStyle, | |
| 206 | width: Int | |
| 207 | ): CodeWrapLayout { | |
| 208 | configureCodePaint(theme.text, 0, style) | |
| 209 | val availableWidth = width - style.changeBarWidthPx - style.gutterWidthPx - | |
| 210 | style.codePaddingPx * 2f | |
| 211 | return codeLayouts.layout(rows, tokens, textPaint, style, theme, availableWidth.toInt()) | |
| 212 | } | |
| 213 | ||
| 180 | 214 | fun lineNumberColor(change: String): Int = when (change) { |
| 181 | 215 | "add" -> theme.addText |
| 182 | 216 | "delete" -> theme.deleteText |
| ... | ... | @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { |
| 198 | 232 | } |
| 199 | 233 | } |
| 200 | 234 | |
| 235 | /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */ | |
| 236 | @Suppress("LongParameterList") | |
| 201 | 237 | fun drawWordDiffRanges( |
| 202 | 238 | canvas: Canvas, |
| 203 | 239 | row: DiffRow, |
| 204 | 240 | codeX: Float, |
| 205 | 241 | top: Int, |
| 206 | bottom: Int | |
| 242 | bottom: Int, | |
| 243 | lines: CodeLines | |
| 207 | 244 | ) { |
| 245 | if (lines.nativeLayout != null) return | |
| 208 | 246 | if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return |
| 209 | 247 | val color = if (row.change == "add") theme.addBar else theme.deleteBar |
| 210 | 248 | backgroundPaint.color = withAlpha(color, 71) |
| ... | ... | @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { |
| 213 | 251 | val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) |
| 214 | 252 | val highlightTop = (top + bottom - highlightHeight) / 2f |
| 215 | 253 | 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 | |
| 224 | 314 | } |
| 225 | 315 | } |
| 226 | 316 |
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | package expo.modules.t3reviewdiff | |
| 2 | ||
| 3 | import android.graphics.Color | |
| 4 | import android.graphics.Paint | |
| 5 | import android.graphics.Typeface | |
| 6 | import android.text.Layout | |
| 7 | import android.text.SpannableString | |
| 8 | import android.text.Spanned | |
| 9 | import android.text.StaticLayout | |
| 10 | import android.text.TextPaint | |
| 11 | import android.text.style.BackgroundColorSpan | |
| 12 | import android.text.style.ForegroundColorSpan | |
| 13 | import android.text.style.StyleSpan | |
| 14 | import android.text.style.UnderlineSpan | |
| 15 | import kotlin.math.ceil | |
| 16 | import kotlin.math.max | |
| 17 | ||
| 18 | /** Text layout is independent of comment heights and vertical row offsets. */ | |
| 19 | internal 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 | ||
| 36 | internal 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. */ | |
| 54 | internal 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 | ||
| 75 | internal class CodeLayoutCache { | |
| 76 | private data class Entry(val row: DiffRow, val tokens: List<DiffToken>?, val lines: CodeLines) | |
| 77 | private var entries = emptyMap<String, Entry>() | |
| 78 | private var previousStyle: DiffStyle? = null | |
| 79 | private var previousTheme: DiffTheme? = null | |
| 80 | private var previousWidth = 0 | |
| 81 | ||
| 82 | /** Entries are immutable; a worker can reuse them without changing the displayed cache. */ | |
| 83 | fun copyForPreparation(): CodeLayoutCache = CodeLayoutCache().also { | |
| 84 | it.entries = entries | |
| 85 | it.previousStyle = previousStyle | |
| 86 | it.previousTheme = previousTheme | |
| 87 | it.previousWidth = previousWidth | |
| 88 | } | |
| 89 | ||
| 90 | @Suppress("LongParameterList") | |
| 91 | fun layout( | |
| 92 | rows: List<DiffRow>, | |
| 93 | tokens: Map<String, List<DiffToken>>, | |
| 94 | paint: Paint, | |
| 95 | style: DiffStyle, | |
| 96 | theme: DiffTheme, | |
| 97 | width: Int | |
| 98 | ): CodeWrapLayout { | |
| 99 | if (!style.wordWrap || width < paint.measureText("M")) { | |
| 100 | entries = emptyMap() | |
| 101 | return CodeWrapLayout.NONE | |
| 102 | } | |
| 103 | if (previousStyle != style || previousTheme != theme || previousWidth != width) { | |
| 104 | entries = emptyMap() | |
| 105 | previousStyle = style | |
| 106 | previousTheme = theme | |
| 107 | previousWidth = width | |
| 108 | } | |
| 109 | val next = HashMap<String, Entry>() | |
| 110 | val layouts = HashMap<String, CodeLines>() | |
| 111 | for (row in rows) { | |
| 112 | if (row.kind != "line") continue | |
| 113 | val rowTokens = tokens[row.id] | |
| 114 | val cached = entries[row.id] | |
| 115 | val entry = if (cached?.row == row && cached.tokens == rowTokens) { | |
| 116 | cached | |
| 117 | } else { | |
| 118 | val text = styledCode(row, rowTokens, theme) | |
| 119 | Entry(row, rowTokens, createCodeLines(text, TextPaint(paint), width)) | |
| 120 | } | |
| 121 | next[row.id] = entry | |
| 122 | layouts[row.id] = entry.lines | |
| 123 | } | |
| 124 | entries = next | |
| 125 | return CodeWrapLayout(true, layouts) | |
| 126 | } | |
| 127 | ||
| 128 | private fun styledCode(row: DiffRow, tokens: List<DiffToken>?, theme: DiffTheme): CharSequence { | |
| 129 | // The ASCII path uses the existing token drawing and rounded highlight rectangles. | |
| 130 | if (row.content.all { it in ' '..'~' }) return row.content | |
| 131 | val text = SpannableString(row.content) | |
| 132 | var offset = 0 | |
| 133 | for (token in tokens.orEmpty()) { | |
| 134 | val end = (offset + token.content.length).coerceAtMost(text.length) | |
| 135 | if (end > offset) { | |
| 136 | token.color?.let { | |
| 137 | text.setSpan(ForegroundColorSpan(it), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) | |
| 138 | } | |
| 139 | val fontStyle = (if (token.fontStyle and 2 != 0) Typeface.BOLD else 0) or | |
| 140 | (if (token.fontStyle and 1 != 0) Typeface.ITALIC else 0) | |
| 141 | if (fontStyle != | |
| 142 | 0 | |
| 143 | ) { | |
| 144 | text.setSpan(StyleSpan(fontStyle), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) | |
| 145 | } | |
| 146 | if (token.fontStyle and 4 != | |
| 147 | 0 | |
| 148 | ) { | |
| 149 | text.setSpan(UnderlineSpan(), offset, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) | |
| 150 | } | |
| 151 | } | |
| 152 | offset = end | |
| 153 | } | |
| 154 | if (row.change == "add" || row.change == "delete") { | |
| 155 | val bar = if (row.change == "add") theme.addBar else theme.deleteBar | |
| 156 | val color = Color.argb(71, Color.red(bar), Color.green(bar), Color.blue(bar)) | |
| 157 | for (range in row.wordDiffRanges) { | |
| 158 | val start = range.start.coerceIn(0, text.length) | |
| 159 | val end = range.end.coerceIn(start, text.length) | |
| 160 | if (end > | |
| 161 | start | |
| 162 | ) { | |
| 163 | text.setSpan(BackgroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) | |
| 164 | } | |
| 165 | } | |
| 166 | } | |
| 167 | return text | |
| 168 | } | |
| 169 | } |
apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt+36-17| ... | ... | @@ -143,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 143 | 143 | fun setRowsJson(value: String) { |
| 144 | 144 | rowsDecodeGeneration += 1 |
| 145 | 145 | val generation = rowsDecodeGeneration |
| 146 | val prepareLayout = canvasView.prepareRows() | |
| 146 | 147 | payloadDecodeExecutor.execute { |
| 147 | 148 | val decodedRows = parseRows(value) |
| 149 | val codeLayouts = prepareLayout(decodedRows) | |
| 148 | 150 | post { |
| 149 | 151 | if (generation != rowsDecodeGeneration) return@post |
| 152 | canvasView.useCodeLayouts(codeLayouts) | |
| 150 | 153 | rows = decodedRows |
| 151 | 154 | lastVisibleFileId = null |
| 152 | 155 | rebuildVisibleRows() |
| ... | ... | @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( |
| 460 | 463 | val end: Int |
| 461 | 464 | ) |
| 462 | 465 | |
| 463 | private data class DiffToken( | |
| 466 | internal data class DiffToken( | |
| 464 | 467 | val content: String, |
| 465 | 468 | val color: Int?, |
| 466 | 469 | val fontStyle: Int |
| ... | ... | @@ -543,6 +546,7 @@ internal data class DiffTheme( |
| 543 | 546 | } |
| 544 | 547 | |
| 545 | 548 | internal data class DiffStyle( |
| 549 | val wordWrap: Boolean, | |
| 546 | 550 | val rowHeightPx: Float, |
| 547 | 551 | val gutterWidthPx: Float, |
| 548 | 552 | val codePaddingPx: Float, |
| ... | ... | @@ -564,6 +568,7 @@ internal data class DiffStyle( |
| 564 | 568 | ) { |
| 565 | 569 | companion object { |
| 566 | 570 | fun defaults(density: Float): DiffStyle = DiffStyle( |
| 571 | wordWrap = false, | |
| 567 | 572 | rowHeightPx = 20f * density, |
| 568 | 573 | gutterWidthPx = 72f * density, |
| 569 | 574 | codePaddingPx = 10f * density, |
| ... | ... | @@ -587,6 +592,7 @@ internal data class DiffStyle( |
| 587 | 592 | fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { |
| 588 | 593 | val json = JSONObject(value) |
| 589 | 594 | DiffStyle( |
| 595 | wordWrap = json.optBoolean("wordWrap", fallback.wordWrap), | |
| 590 | 596 | rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), |
| 591 | 597 | gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), |
| 592 | 598 | codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), |
| ... | ... | @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 684 | 690 | }, |
| 685 | 691 | ) |
| 686 | 692 | private var rowOffsets = intArrayOf(0) |
| 693 | ||
| 694 | private var codeWrap = CodeWrapLayout.NONE | |
| 687 | 695 | private var verticalOffset = 0 |
| 688 | 696 | private var horizontalOffset = 0 |
| 689 | 697 | private val headerPathOffsetsByFileId = mutableMapOf<String, Int>() |
| ... | ... | @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 699 | 707 | var tokensByRowId: Map<String, List<DiffToken>> = emptyMap() |
| 700 | 708 | set(value) { |
| 701 | 709 | field = value |
| 710 | if (style.wordWrap) rebuildOffsets() | |
| 702 | 711 | invalidate() |
| 703 | 712 | } |
| 704 | 713 | var viewedFileIds: Set<String> = emptySet() |
| ... | ... | @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 725 | 734 | set(value) { |
| 726 | 735 | field = value |
| 727 | 736 | drawing.theme = value |
| 737 | if (style.wordWrap) rebuildOffsets() | |
| 728 | 738 | invalidate() |
| 729 | 739 | } |
| 730 | 740 | var style: DiffStyle = DiffStyle.defaults(density) |
| ... | ... | @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 742 | 752 | var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null |
| 743 | 753 | var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null |
| 744 | 754 | |
| 755 | fun prepareRows() = drawing.prepareRows(tokensByRowId, style, width) | |
| 756 | fun useCodeLayouts(layouts: CodeLayoutCache) { | |
| 757 | drawing.codeLayouts = layouts | |
| 758 | } | |
| 759 | ||
| 745 | 760 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { |
| 746 | 761 | setMeasuredDimension( |
| 747 | 762 | MeasureSpec.getSize(widthMeasureSpec), |
| ... | ... | @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 751 | 766 | |
| 752 | 767 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { |
| 753 | 768 | super.onSizeChanged(width, height, oldWidth, oldHeight) |
| 769 | // Wrapped rows take their height from the width, so a new width is a new layout. | |
| 770 | if (style.wordWrap && width != oldWidth) layoutRows() | |
| 754 | 771 | setVerticalOffset(verticalOffset) |
| 755 | 772 | setHorizontalOffset(horizontalOffset) |
| 756 | 773 | clampHeaderPathOffsets() |
| ... | ... | @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 818 | 835 | |
| 819 | 836 | fun horizontalOffset(): Int = horizontalOffset |
| 820 | 837 | |
| 821 | fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) | |
| 838 | fun maxHorizontalOffset(): Int = | |
| 839 | if (codeWrap.enabled) 0 else max(0, contentWidthPx - width) | |
| 822 | 840 | |
| 823 | 841 | fun maxHorizontalOffset(target: HorizontalPanTarget): Int = |
| 824 | 842 | if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { |
| ... | ... | @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 844 | 862 | } |
| 845 | 863 | |
| 846 | 864 | private fun rebuildOffsets() { |
| 865 | layoutRows() | |
| 866 | requestLayout() | |
| 867 | invalidate() | |
| 868 | } | |
| 869 | ||
| 870 | private fun layoutRows() { | |
| 871 | codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) | |
| 847 | 872 | rowOffsets = IntArray(rows.size + 1) |
| 848 | 873 | rows.forEachIndexed { index, row -> |
| 849 | 874 | rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) |
| 850 | 875 | } |
| 851 | 876 | setVerticalOffset(verticalOffset) |
| 877 | setHorizontalOffset(horizontalOffset) | |
| 852 | 878 | clampHeaderPathOffsets() |
| 853 | requestLayout() | |
| 854 | invalidate() | |
| 855 | 879 | } |
| 856 | 880 | |
| 857 | 881 | private fun rowHeight(row: DiffRow): Int = when (row.kind) { |
| ... | ... | @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 862 | 886 | } else { |
| 863 | 887 | (124 * density).toInt() |
| 864 | 888 | } |
| 889 | "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) | |
| 865 | 890 | else -> style.rowHeightPx.toInt() |
| 866 | 891 | }.coerceAtLeast(1) |
| 867 | 892 | |
| ... | ... | @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 1191 | 1216 | ) |
| 1192 | 1217 | } |
| 1193 | 1218 | |
| 1194 | val tokens = tokensByRowId[row.id] | |
| 1219 | // Wrapped rows keep the line number and first code line in the first row-height band. | |
| 1220 | val lines = codeWrap.lines(row.id) | |
| 1221 | val firstLineBottom = top + lines.firstHeight(style.rowHeightPx.toInt()) | |
| 1195 | 1222 | drawScrollableCode(canvas, top, bottom) { codeX -> |
| 1196 | 1223 | 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) | |
| 1208 | 1227 | } |
| 1209 | 1228 | |
| 1210 | drawLineNumber(canvas, row, top, bottom) | |
| 1229 | drawLineNumber(canvas, row, top, firstLineBottom) | |
| 1211 | 1230 | } |
| 1212 | 1231 | |
| 1213 | 1232 | private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { |
apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt created+150| ... | ... | @@ -0,0 +1,150 @@ |
| 1 | package expo.modules.t3reviewdiff | |
| 2 | ||
| 3 | import android.graphics.Bitmap | |
| 4 | import android.graphics.Canvas | |
| 5 | import android.graphics.Color | |
| 6 | import android.graphics.Typeface | |
| 7 | import android.text.Spanned | |
| 8 | import android.text.TextPaint | |
| 9 | import android.text.style.BackgroundColorSpan | |
| 10 | import org.junit.Assert.assertEquals | |
| 11 | import org.junit.Assert.assertNotSame | |
| 12 | import org.junit.Assert.assertSame | |
| 13 | import org.junit.Assert.assertTrue | |
| 14 | import org.junit.Test | |
| 15 | import org.junit.runner.RunWith | |
| 16 | import org.robolectric.RobolectricTestRunner | |
| 17 | import org.robolectric.annotation.Config | |
| 18 | import org.robolectric.annotation.GraphicsMode | |
| 19 | ||
| 20 | @RunWith(RobolectricTestRunner::class) | |
| 21 | @Config(sdk = [35], manifest = Config.NONE) | |
| 22 | @GraphicsMode(GraphicsMode.Mode.NATIVE) | |
| 23 | class 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 @@ |
| 1 | import UIKit | |
| 2 | ||
| 3 | /// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. | |
| 4 | final 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 | ||
| 99 | private final class ReviewDiffTextLayout { | |
| 100 | let storage = NSTextStorage() | |
| 101 | let manager = NSLayoutManager() | |
| 102 | let container = NSTextContainer(size: .zero) | |
| 103 | var decorationVersion = -1 | |
| 104 | ||
| 105 | init() { | |
| 106 | container.lineFragmentPadding = 0 | |
| 107 | container.lineBreakMode = .byCharWrapping | |
| 108 | manager.addTextContainer(container) | |
| 109 | storage.addLayoutManager(manager) | |
| 110 | } | |
| 111 | ||
| 112 | func configure(text: String, font: UIFont, width: CGFloat, characterWidth: CGFloat) { | |
| 113 | let paragraph = NSMutableParagraphStyle() | |
| 114 | paragraph.lineBreakMode = .byCharWrapping | |
| 115 | paragraph.tabStops = [] | |
| 116 | paragraph.defaultTabInterval = characterWidth * 4 | |
| 117 | container.size = CGSize(width: max(1, width), height: .greatestFiniteMagnitude) | |
| 118 | storage.setAttributedString(NSAttributedString(string: text, attributes: [ | |
| 119 | .font: font, .ligature: 0, .paragraphStyle: paragraph, | |
| 120 | ])) | |
| 121 | manager.ensureLayout(for: container) | |
| 122 | } | |
| 123 | } |
apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift+222-62| ... | ... | @@ -137,6 +137,7 @@ private struct ReviewDiffNativeTheme { |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | 139 | private struct ReviewDiffNativeStylePayload: Decodable { |
| 140 | let wordWrap: Bool? | |
| 140 | 141 | let rowHeight: Double? |
| 141 | 142 | let contentWidth: Double? |
| 142 | 143 | let changeBarWidth: Double? |
| ... | ... | @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { |
| 170 | 171 | } |
| 171 | 172 | |
| 172 | 173 | private struct ReviewDiffNativeStyle { |
| 174 | let wordWrap: Bool | |
| 173 | 175 | let rowHeight: CGFloat |
| 174 | 176 | let contentWidth: CGFloat |
| 175 | 177 | let changeBarWidth: CGFloat |
| ... | ... | @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { |
| 203 | 205 | |
| 204 | 206 | static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { |
| 205 | 207 | ReviewDiffNativeStyle( |
| 208 | wordWrap: payload?.wordWrap ?? false, | |
| 206 | 209 | rowHeight: metric(payload?.rowHeight, fallback: 24), |
| 207 | 210 | contentWidth: metric(payload?.contentWidth, fallback: 2800), |
| 208 | 211 | changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), |
| ... | ... | @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { |
| 277 | 280 | |
| 278 | 281 | func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { |
| 279 | 282 | ReviewDiffNativeStyle( |
| 283 | wordWrap: wordWrap, | |
| 280 | 284 | rowHeight: rowHeight ?? self.rowHeight, |
| 281 | 285 | contentWidth: contentWidth ?? self.contentWidth, |
| 282 | 286 | changeBarWidth: changeBarWidth, |
| ... | ... | @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 434 | 438 | guard let self, generation == self.rowsDecodeGeneration else { |
| 435 | 439 | return |
| 436 | 440 | } |
| 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 | }) | |
| 447 | 456 | } |
| 448 | 457 | } catch { |
| 449 | 458 | let message = error.localizedDescription |
| ... | ... | @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 652 | 661 | |
| 653 | 662 | private func updateContentMetrics() { |
| 654 | 663 | let style = contentView.style |
| 664 | contentView.viewportWidth = bounds.width | |
| 655 | 665 | let height = max(bounds.height, contentView.contentHeight) |
| 656 | 666 | let width = bounds.width |
| 657 | 667 | scrollView.contentSize = CGSize(width: bounds.width, height: height) |
| ... | ... | @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 661 | 671 | width: max(width, 1), |
| 662 | 672 | height: max(bounds.height, 1) |
| 663 | 673 | ) |
| 664 | contentView.viewportWidth = bounds.width | |
| 665 | 674 | contentView.verticalOffset = scrollView.contentOffset.y |
| 666 | 675 | contentView.invalidateVisibleViewport() |
| 667 | 676 | contentView.setNeedsDisplay() |
| ... | ... | @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 929 | 938 | headerPathOffsetsByFileId.removeAll() |
| 930 | 939 | activePanFileId = nil |
| 931 | 940 | activePanKind = nil |
| 941 | codeDecorationVersion += 1 | |
| 932 | 942 | tokenAttributedStringsByRowId.removeAll() |
| 933 | 943 | rebuildRowLayout() |
| 934 | 944 | setNeedsDisplayForVisibleBounds() |
| ... | ... | @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 936 | 946 | } |
| 937 | 947 | var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { |
| 938 | 948 | didSet { |
| 949 | codeDecorationVersion += 1 | |
| 939 | 950 | tokenAttributedStringsByRowId.removeAll() |
| 940 | 951 | clampHorizontalOffsets() |
| 941 | 952 | setNeedsDisplayForVisibleBounds() |
| ... | ... | @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 976 | 987 | } |
| 977 | 988 | var style = ReviewDiffNativeStyle.resolve(nil) { |
| 978 | 989 | didSet { |
| 990 | codeDecorationVersion += 1 | |
| 979 | 991 | tokenAttributedStringsByRowId.removeAll() |
| 980 | 992 | rebuildRowLayout() |
| 981 | 993 | clampHorizontalOffsets() |
| ... | ... | @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 984 | 996 | } |
| 985 | 997 | var viewportWidth: CGFloat = 0 { |
| 986 | 998 | didSet { |
| 999 | // Wrapped rows take their height from the width, so a new width is a new layout. | |
| 1000 | if style.wordWrap, viewportWidth != oldValue { | |
| 1001 | rebuildRowLayout() | |
| 1002 | } | |
| 987 | 1003 | clampHorizontalOffsets() |
| 988 | 1004 | setNeedsDisplayForVisibleBounds() |
| 989 | 1005 | } |
| ... | ... | @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 992 | 1008 | var theme = ReviewDiffNativeTheme.resolve("light") { |
| 993 | 1009 | didSet { |
| 994 | 1010 | tokenColorsByHex.removeAll() |
| 1011 | codeDecorationVersion += 1 | |
| 995 | 1012 | tokenAttributedStringsByRowId.removeAll() |
| 996 | 1013 | setNeedsDisplayForVisibleBounds() |
| 997 | 1014 | } |
| ... | ... | @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1004 | 1021 | private var tokenColorsByHex: [String: UIColor] = [:] |
| 1005 | 1022 | private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] |
| 1006 | 1023 | private var codeCharacterWidth: CGFloat = 8 |
| 1024 | /// Columns per visual line while word wrap is on; nil while code rows pan horizontally. | |
| 1025 | private var codeWrapColumns: Int? | |
| 1026 | /// Text geometry survives comment height changes; width, font, and content invalidate it. | |
| 1027 | private var codeLayoutsByRowId: [String: ReviewDiffCodeLayout] = [:] | |
| 1028 | private var codeLayoutWidth: CGFloat = 0 | |
| 1029 | private var codeLayoutFont: UIFont? | |
| 1030 | private var codeDecorationVersion = 0 | |
| 1007 | 1031 | private var panStartHorizontalOffset: CGFloat = 0 |
| 1008 | 1032 | private var activePanFileId: String? |
| 1009 | 1033 | private var activePanKind: ReviewDiffHorizontalPanKind? |
| ... | ... | @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1029 | 1053 | stickyWidth + style.codePadding |
| 1030 | 1054 | } |
| 1031 | 1055 | |
| 1056 | /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. | |
| 1032 | 1057 | private func height(for row: ReviewDiffNativeRow) -> CGFloat { |
| 1033 | 1058 | if row.kind == "file" { |
| 1034 | 1059 | return style.fileHeaderHeight |
| ... | ... | @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1045 | 1070 | return style.rowHeight |
| 1046 | 1071 | } |
| 1047 | 1072 | |
| 1073 | /// Laid-out height, including wrapped lines. Requires a layout built from the current rows. | |
| 1074 | private func height(at index: Int) -> CGFloat { | |
| 1075 | let nextOffset = index + 1 < rowOffsets.count ? rowOffsets[index + 1] : contentHeight | |
| 1076 | return nextOffset - rowOffsets[index] | |
| 1077 | } | |
| 1078 | ||
| 1079 | private var codeWrapLineHeight: CGFloat { | |
| 1080 | ceil(codeFont.lineHeight) | |
| 1081 | } | |
| 1082 | ||
| 1048 | 1083 | func frameForRow(at index: Int) -> CGRect? { |
| 1049 | 1084 | guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { |
| 1050 | 1085 | return nil |
| ... | ... | @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1054 | 1089 | x: 0, |
| 1055 | 1090 | y: rowOffsets[index], |
| 1056 | 1091 | width: max(viewportWidth, 1), |
| 1057 | height: height(for: rows[index]) | |
| 1092 | height: height(at: index) | |
| 1058 | 1093 | ) |
| 1059 | 1094 | } |
| 1060 | 1095 | |
| 1096 | /// Shape new content on the existing decode worker before publishing rows to the UI. | |
| 1097 | func prepareRows( | |
| 1098 | _ rows: [ReviewDiffNativeRow], | |
| 1099 | on queue: DispatchQueue, | |
| 1100 | isCurrent: @escaping () -> Bool, | |
| 1101 | completion: @escaping () -> Void | |
| 1102 | ) { | |
| 1103 | let font = codeFont | |
| 1104 | let width = viewportWidth - codeStartX - style.codePadding | |
| 1105 | let characterWidth = monospaceCharacterWidth(font: font) | |
| 1106 | guard style.wordWrap, width >= characterWidth, characterWidth > 0 else { | |
| 1107 | completion() | |
| 1108 | return | |
| 1109 | } | |
| 1110 | let cached = codeLayoutWidth == width && codeLayoutFont == font ? codeLayoutsByRowId : [:] | |
| 1111 | queue.async { [weak self] in | |
| 1112 | var layouts: [String: ReviewDiffCodeLayout] = [:] | |
| 1113 | for row in rows where row.kind == "line" { | |
| 1114 | guard let text = row.content else { continue } | |
| 1115 | if let previous = cached[row.id], previous.text == text { | |
| 1116 | layouts[row.id] = previous | |
| 1117 | } else { | |
| 1118 | layouts[row.id] = ReviewDiffCodeLayout(text: text, font: font, width: width, characterWidth: characterWidth) | |
| 1119 | } | |
| 1120 | } | |
| 1121 | DispatchQueue.main.async { [weak self] in | |
| 1122 | guard let self, isCurrent() else { return } | |
| 1123 | if self.codeFont != font || self.viewportWidth - self.codeStartX - self.style.codePadding != width { | |
| 1124 | self.prepareRows(rows, on: queue, isCurrent: isCurrent, completion: completion) | |
| 1125 | return | |
| 1126 | } | |
| 1127 | self.codeLayoutWidth = width | |
| 1128 | self.codeLayoutFont = font | |
| 1129 | self.codeLayoutsByRowId = layouts | |
| 1130 | completion() | |
| 1131 | } | |
| 1132 | } | |
| 1133 | } | |
| 1134 | ||
| 1061 | 1135 | private func rebuildRowLayout() { |
| 1062 | 1136 | var nextOffsets: [CGFloat] = [] |
| 1063 | 1137 | var nextFileHeaderRowIndices: [Int] = [] |
| 1064 | 1138 | nextOffsets.reserveCapacity(rows.count) |
| 1065 | 1139 | var maxColumnCountsByFileId: [String: Int] = [:] |
| 1140 | var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] | |
| 1066 | 1141 | 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 | } | |
| 1067 | 1153 | |
| 1068 | 1154 | for (index, row) in rows.enumerated() { |
| 1069 | 1155 | nextOffsets.append(offset) |
| 1070 | 1156 | if row.kind == "file" { |
| 1071 | 1157 | nextFileHeaderRowIndices.append(index) |
| 1072 | 1158 | } |
| 1073 | offset += height(for: row) | |
| 1159 | var rowHeight = height(for: row) | |
| 1074 | 1160 | |
| 1075 | 1161 | let fileId = resolvedFileId(for: row) |
| 1076 | 1162 | switch row.kind { |
| 1077 | 1163 | 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 | } | |
| 1082 | 1182 | case "hunk": |
| 1083 | 1183 | maxColumnCountsByFileId[fileId] = max( |
| 1084 | 1184 | maxColumnCountsByFileId[fileId] ?? 0, |
| 1085 | row.text?.count ?? 0 | |
| 1185 | row.text?.utf16.count ?? 0 | |
| 1086 | 1186 | ) |
| 1087 | 1187 | default: |
| 1088 | continue | |
| 1188 | break | |
| 1089 | 1189 | } |
| 1190 | offset += rowHeight | |
| 1090 | 1191 | } |
| 1091 | 1192 | |
| 1092 | let characterWidth = monospaceCharacterWidth(font: codeFont) | |
| 1093 | 1193 | codeCharacterWidth = characterWidth |
| 1194 | codeWrapColumns = wrapColumns | |
| 1195 | codeLayoutsByRowId = nextCodeLayouts | |
| 1094 | 1196 | contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in |
| 1095 | 1197 | let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 |
| 1096 | 1198 | return max(0, min(style.contentWidth, measuredWidth)) |
| ... | ... | @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1498 | 1600 | while lowerBound <= upperBound { |
| 1499 | 1601 | let midpoint = (lowerBound + upperBound) / 2 |
| 1500 | 1602 | let rowStart = rowOffsets[midpoint] |
| 1501 | let rowEnd = rowStart + height(for: rows[midpoint]) | |
| 1603 | let rowEnd = rowStart + height(at: midpoint) | |
| 1502 | 1604 | |
| 1503 | 1605 | if absoluteY < rowStart { |
| 1504 | 1606 | upperBound = midpoint - 1 |
| ... | ... | @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1521 | 1623 | var upperBound = rows.count |
| 1522 | 1624 | while lowerBound < upperBound { |
| 1523 | 1625 | let midpoint = (lowerBound + upperBound) / 2 |
| 1524 | let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint]) | |
| 1626 | let rowEnd = rowOffsets[midpoint] + height(at: midpoint) | |
| 1525 | 1627 | |
| 1526 | 1628 | if rowEnd < absoluteY { |
| 1527 | 1629 | lowerBound = midpoint + 1 |
| ... | ... | @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1609 | 1711 | let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { |
| 1610 | 1712 | return maxHeaderPathOffset(for: row) |
| 1611 | 1713 | } |
| 1714 | if codeWrapColumns != nil { | |
| 1715 | return 0 | |
| 1716 | } | |
| 1612 | 1717 | |
| 1613 | 1718 | return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) |
| 1614 | 1719 | } |
| ... | ... | @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1728 | 1833 | var drawnRowCount = 0 |
| 1729 | 1834 | for rowIndex in firstRowIndex...lastRowIndex { |
| 1730 | 1835 | let rowStart = rowOffsets[rowIndex] |
| 1731 | let rowHeight = height(for: rows[rowIndex]) | |
| 1836 | let rowHeight = height(at: rowIndex) | |
| 1732 | 1837 | if rowHeight <= 0 { |
| 1733 | 1838 | continue |
| 1734 | 1839 | } |
| ... | ... | @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1786 | 1891 | |
| 1787 | 1892 | private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { |
| 1788 | 1893 | 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)) | |
| 1790 | 1895 | |
| 1791 | 1896 | switch row.kind { |
| 1792 | 1897 | case "file": |
| ... | ... | @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2172 | 2277 | let horizontalOffset = horizontalOffset(for: fileId) |
| 2173 | 2278 | let contentWidth = contentWidth(for: fileId) |
| 2174 | 2279 | let change = row.change ?? "context" |
| 2280 | // Wrapped rows keep the line number and first code line in the first row-height band. | |
| 2281 | let layout = codeLayoutsByRowId[row.id] | |
| 2282 | let firstLineRect = CGRect( | |
| 2283 | x: rect.minX, y: rect.minY, width: rect.width, | |
| 2284 | height: max(style.rowHeight, layout?.firstLineHeight ?? 0) | |
| 2285 | ) | |
| 2175 | 2286 | rowBackground(for: change).setFill() |
| 2176 | 2287 | context.fill(rect) |
| 2177 | 2288 | |
| 2178 | 2289 | if change == "add" { |
| 2179 | 2290 | theme.addBar.setFill() |
| 2180 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) | |
| 2291 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) | |
| 2181 | 2292 | } else if change == "delete" { |
| 2182 | 2293 | drawDeleteStripes( |
| 2183 | rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), | |
| 2294 | rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), | |
| 2184 | 2295 | context: context |
| 2185 | 2296 | ) |
| 2186 | 2297 | } |
| ... | ... | @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2193 | 2304 | "\(lineNumber)", |
| 2194 | 2305 | rect: CGRect( |
| 2195 | 2306 | x: style.changeBarWidth, |
| 2196 | y: centeredTextY(in: rect, font: lineNumberFont), | |
| 2307 | y: centeredTextY(in: firstLineRect, font: lineNumberFont), | |
| 2197 | 2308 | width: style.gutterWidth - style.codePadding, |
| 2198 | 2309 | height: lineNumberFont.lineHeight |
| 2199 | 2310 | ), |
| ... | ... | @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2203 | 2314 | } |
| 2204 | 2315 | |
| 2205 | 2316 | context.saveGState() |
| 2206 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) | |
| 2317 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) | |
| 2207 | 2318 | let codeTextRect = CGRect( |
| 2208 | 2319 | x: codeStartX - horizontalOffset, |
| 2209 | y: centeredTextY(in: rect, font: codeFont), | |
| 2320 | y: centeredTextY(in: firstLineRect, font: codeFont), | |
| 2210 | 2321 | width: contentWidth, |
| 2211 | 2322 | height: codeFont.lineHeight |
| 2212 | 2323 | ) |
| 2213 | drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset) | |
| 2324 | if let layout, layout.usesNativeLayout { | |
| 2325 | let text = tokensByRowId[row.id].map { | |
| 2326 | tokenAttributedString(rowId: row.id, tokens: $0, fallbackColor: theme.text, font: codeFont) | |
| 2327 | } ?? NSAttributedString(string: row.content ?? "", attributes: [.foregroundColor: theme.text]) | |
| 2328 | let highlights = (change == "add" || change == "delete") ? (row.wordDiffRanges ?? []) : [] | |
| 2329 | layout.decorate( | |
| 2330 | text: text, | |
| 2331 | highlights: highlights.filter { $0.start >= 0 && $0.end > $0.start }.map { | |
| 2332 | NSRange(location: $0.start, length: $0.end - $0.start) | |
| 2333 | }, | |
| 2334 | color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), | |
| 2335 | version: codeDecorationVersion | |
| 2336 | ) | |
| 2337 | layout.draw( | |
| 2338 | at: CGPoint(x: codeStartX, y: rect.minY + max(0, (firstLineRect.height - layout.firstLineHeight) / 2)), | |
| 2339 | clip: context.boundingBoxOfClipPath | |
| 2340 | ) | |
| 2341 | context.restoreGState() | |
| 2342 | return | |
| 2343 | } | |
| 2344 | let lineStarts = layout?.starts ?? [0] | |
| 2345 | drawWordDiffRanges( | |
| 2346 | row, | |
| 2347 | lineStarts: lineStarts, | |
| 2348 | firstLineRect: firstLineRect, | |
| 2349 | context: context, | |
| 2350 | horizontalOffset: horizontalOffset | |
| 2351 | ) | |
| 2214 | 2352 | if let tokens = tokensByRowId[row.id], !tokens.isEmpty { |
| 2215 | drawTokenText( | |
| 2353 | let attributedText = tokenAttributedString( | |
| 2216 | 2354 | rowId: row.id, |
| 2217 | tokens, | |
| 2218 | rect: codeTextRect, | |
| 2355 | tokens: tokens, | |
| 2219 | 2356 | fallbackColor: theme.text, |
| 2220 | 2357 | font: codeFont |
| 2221 | 2358 | ) |
| 2359 | drawCodeLines(length: attributedText.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in | |
| 2360 | let segment = range.length == attributedText.length | |
| 2361 | ? attributedText | |
| 2362 | : attributedText.attributedSubstring(from: range) | |
| 2363 | segment.draw(in: lineRect) | |
| 2364 | } | |
| 2222 | 2365 | } else { |
| 2223 | drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) | |
| 2366 | let content = (row.content ?? "") as NSString | |
| 2367 | drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in | |
| 2368 | let segment = range.length == content.length ? content as String : content.substring(with: range) | |
| 2369 | drawText(segment, rect: lineRect, color: theme.text, font: codeFont) | |
| 2370 | } | |
| 2224 | 2371 | } |
| 2225 | 2372 | context.restoreGState() |
| 2226 | 2373 | } |
| 2227 | 2374 | |
| 2375 | /// Draws the segment starting at each of the row's line starts on its own visual line. | |
| 2376 | private func drawCodeLines( | |
| 2377 | length: Int, | |
| 2378 | lineStarts: [Int], | |
| 2379 | firstLineRect: CGRect, | |
| 2380 | draw: (NSRange, CGRect) -> Void | |
| 2381 | ) { | |
| 2382 | var lineRect = firstLineRect | |
| 2383 | let clip = UIGraphicsGetCurrentContext()?.boundingBoxOfClipPath ?? bounds | |
| 2384 | let first = max(0, Int(floor((clip.minY - firstLineRect.minY) / codeWrapLineHeight))) | |
| 2385 | let last = min(lineStarts.count, Int(ceil((clip.maxY - firstLineRect.minY) / codeWrapLineHeight))) | |
| 2386 | guard first < last else { return } | |
| 2387 | lineRect.origin.y += CGFloat(first) * codeWrapLineHeight | |
| 2388 | for line in first..<last { | |
| 2389 | let lineStart = lineStarts[line] | |
| 2390 | let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : length | |
| 2391 | draw(NSRange(location: lineStart, length: lineEnd - lineStart), lineRect) | |
| 2392 | lineRect.origin.y += codeWrapLineHeight | |
| 2393 | } | |
| 2394 | } | |
| 2395 | ||
| 2228 | 2396 | private func drawSelectionOverlay( |
| 2229 | 2397 | _ row: ReviewDiffNativeRow, |
| 2230 | 2398 | rect: CGRect, |
| ... | ... | @@ -2244,7 +2412,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2244 | 2412 | |
| 2245 | 2413 | private func drawWordDiffRanges( |
| 2246 | 2414 | _ row: ReviewDiffNativeRow, |
| 2247 | rowRect: CGRect, | |
| 2415 | lineStarts: [Int], | |
| 2416 | firstLineRect: CGRect, | |
| 2248 | 2417 | context: CGContext, |
| 2249 | 2418 | horizontalOffset: CGFloat |
| 2250 | 2419 | ) { |
| ... | ... | @@ -2263,8 +2432,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2263 | 2432 | } else { |
| 2264 | 2433 | fillColor = theme.deleteBar.withAlphaComponent(0.28) |
| 2265 | 2434 | } |
| 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 | |
| 2268 | 2437 | |
| 2269 | 2438 | fillColor.setFill() |
| 2270 | 2439 | for range in ranges { |
| ... | ... | @@ -2272,15 +2441,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2272 | 2441 | continue |
| 2273 | 2442 | } |
| 2274 | 2443 | |
| 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 | } | |
| 2284 | 2460 | } |
| 2285 | 2461 | } |
| 2286 | 2462 | |
| ... | ... | @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2448 | 2624 | return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) |
| 2449 | 2625 | } |
| 2450 | 2626 | |
| 2451 | private func drawTokenText( | |
| 2452 | rowId: String, | |
| 2453 | _ tokens: [ReviewDiffNativeToken], | |
| 2454 | rect: CGRect, | |
| 2455 | fallbackColor: UIColor, | |
| 2456 | font: UIFont | |
| 2457 | ) { | |
| 2458 | let attributedText = tokenAttributedString( | |
| 2459 | rowId: rowId, | |
| 2460 | tokens: tokens, | |
| 2461 | fallbackColor: fallbackColor, | |
| 2462 | font: font | |
| 2463 | ) | |
| 2464 | attributedText.draw(in: rect) | |
| 2465 | } | |
| 2466 | ||
| 2467 | 2627 | private func tokenAttributedString( |
| 2468 | 2628 | rowId: String, |
| 2469 | 2629 | tokens: [ReviewDiffNativeToken], |
apps/mobile/modules/t3-review-diff/tests/ios/main.swift created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import UIKit | |
| 2 | ||
| 3 | func 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. | |
| 11 | let font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) | |
| 12 | let characterWidth = ("M" as NSString).size(withAttributes: [.font: font]).width | |
| 13 | let fixtures = ["漢字表示", "e\u{301}", "👨👩👧👦", "مرحبا بالعالم ", "\tvalue "] | |
| 14 | var cases = 0 | |
| 15 | for 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 | } | |
| 49 | let ascii = String(repeating: "const value = 123; ", count: 100) | |
| 50 | let layout = ReviewDiffCodeLayout(text: ascii, font: font, width: 280, characterWidth: characterWidth) | |
| 51 | let string = ascii as NSString | |
| 52 | var reconstructed = "" | |
| 53 | for (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 | } | |
| 59 | check(reconstructed == ascii) | |
| 60 | print("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 | |
| 2 | set -euo pipefail | |
| 3 | module_dir=$(cd "$(dirname "$0")/.." && pwd) | |
| 4 | sdk=$(xcrun --sdk macosx --show-sdk-path) | |
| 5 | compiler=$(xcrun --find swiftc) | |
| 6 | output=$(mktemp -d) | |
| 7 | trap 'rm -rf "$output"' EXIT | |
| 8 | "$compiler" -O -target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" \ | |
| 9 | -F "$sdk/System/iOSSupport/System/Library/Frameworks" \ | |
| 10 | "$module_dir/ios/ReviewDiffCodeLayout.swift" "$module_dir/tests/ios/main.swift" \ | |
| 11 | -o "$output/layout-tests" | |
| 12 | "$output/layout-tests" |
apps/mobile/src/features/review/ReviewCommentCard.tsx+2-1| ... | ... | @@ -110,8 +110,9 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 110 | 110 | () => JSON.stringify(nativeReviewDiffTheme), |
| 111 | 111 | [nativeReviewDiffTheme], |
| 112 | 112 | ); |
| 113 | // The card's height is sized from its row count, so its snippet stays unwrapped. | |
| 113 | 114 | const nativeStyleJson = useMemo( |
| 114 | () => JSON.stringify(nativeReviewDiffStyle), | |
| 115 | () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), | |
| 115 | 116 | [nativeReviewDiffStyle], |
| 116 | 117 | ); |
| 117 | 118 | const nativeDiffHeight = useMemo( |
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+6-1| ... | ... | @@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string { |
| 63 | 63 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { | |
| 66 | /** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */ | |
| 67 | export function createNativeReviewDiffStyle( | |
| 68 | codeSurface: ResolvedMobileCodeSurface, | |
| 69 | wordWrap: boolean, | |
| 70 | ) { | |
| 67 | 71 | return { |
| 72 | wordWrap, | |
| 68 | 73 | rowHeight: codeSurface.rowHeight, |
| 69 | 74 | contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, |
| 70 | 75 | changeBarWidth: 4, |
apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { createReviewDiffHighlightScheduler } from "./reviewDiffHighlightScheduler"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | export interface NativeReviewVisibleRange { | |
| 2 | readonly firstRowIndex: number; | |
| 3 | readonly lastRowIndex: number; | |
| 4 | } | |
| 5 | ||
| 6 | export function createReviewDiffHighlightScheduler( | |
| 7 | request: (range: NativeReviewVisibleRange) => void, | |
| 8 | ) { | |
| 9 | let requestedRange: NativeReviewVisibleRange = { firstRowIndex: 0, lastRowIndex: 80 }; | |
| 10 | let visibleRange = requestedRange; | |
| 11 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 12 | ||
| 13 | const cancel = () => { | |
| 14 | clearTimeout(timer); | |
| 15 | timer = undefined; | |
| 16 | }; | |
| 17 | const flush = () => { | |
| 18 | cancel(); | |
| 19 | requestedRange = visibleRange; | |
| 20 | request(visibleRange); | |
| 21 | }; | |
| 22 | ||
| 23 | return { | |
| 24 | update(nextRange: NativeReviewVisibleRange) { | |
| 25 | if ( | |
| 26 | nextRange.firstRowIndex === visibleRange.firstRowIndex && | |
| 27 | nextRange.lastRowIndex === visibleRange.lastRowIndex | |
| 28 | ) { | |
| 29 | return; | |
| 30 | } | |
| 31 | visibleRange = nextRange; | |
| 32 | cancel(); | |
| 33 | // Accumulate small scroll events relative to the last request, not each other. | |
| 34 | const movedRows = | |
| 35 | Math.abs(nextRange.firstRowIndex - requestedRange.firstRowIndex) + | |
| 36 | Math.abs(nextRange.lastRowIndex - requestedRange.lastRowIndex); | |
| 37 | if (movedRows >= 20) { | |
| 38 | flush(); | |
| 39 | } else if (movedRows > 0) { | |
| 40 | // Cover the final viewport even when scrolling stops below the threshold. | |
| 41 | timer = setTimeout(flush, 150); | |
| 42 | } | |
| 43 | }, | |
| 44 | reset() { | |
| 45 | cancel(); | |
| 46 | requestedRange = { firstRowIndex: 0, lastRowIndex: 80 }; | |
| 47 | visibleRange = requestedRange; | |
| 48 | }, | |
| 49 | cancel, | |
| 50 | }; | |
| 51 | } |
apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts+14-27| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 1 | import { useEffect, useRef, useState } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | highlightNativeReviewDiffVisibleRows, |
| ... | ... | @@ -8,10 +8,10 @@ import { |
| 8 | 8 | import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; |
| 9 | 9 | import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; |
| 10 | 10 | |
| 11 | interface NativeReviewVisibleRange { | |
| 12 | readonly firstRowIndex: number; | |
| 13 | readonly lastRowIndex: number; | |
| 14 | } | |
| 11 | import { | |
| 12 | createReviewDiffHighlightScheduler, | |
| 13 | type NativeReviewVisibleRange, | |
| 14 | } from "./reviewDiffHighlightScheduler"; | |
| 15 | 15 | |
| 16 | 16 | function createEmptyTokenPatch(resetKey: string): string { |
| 17 | 17 | return JSON.stringify({ resetKey, tokensByRowId: {} }); |
| ... | ... | @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { |
| 43 | 43 | }) { |
| 44 | 44 | const { enabled, files, resetKey, rows, scheme } = input; |
| 45 | 45 | const highlightedRowIdsRef = useRef<Set<string>>(new Set()); |
| 46 | const visibleRangeRef = useRef<NativeReviewVisibleRange>({ | |
| 46 | const [visibleRange, setVisibleRange] = useState<NativeReviewVisibleRange>({ | |
| 47 | 47 | firstRowIndex: 0, |
| 48 | 48 | lastRowIndex: 80, |
| 49 | 49 | }); |
| 50 | 50 | const visibleChunkIndexRef = useRef(0); |
| 51 | 51 | const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); |
| 52 | const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); | |
| 52 | const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); | |
| 53 | 53 | |
| 54 | 54 | useEffect(() => { |
| 55 | scheduler.reset(); | |
| 55 | 56 | highlightedRowIdsRef.current = new Set(); |
| 56 | 57 | visibleChunkIndexRef.current = 0; |
| 57 | visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; | |
| 58 | setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); | |
| 58 | 59 | 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]); | |
| 63 | 62 | |
| 64 | 63 | useEffect(() => { |
| 65 | 64 | if (!enabled || rows.length === 0) { |
| ... | ... | @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { |
| 67 | 66 | } |
| 68 | 67 | |
| 69 | 68 | const abortController = new AbortController(); |
| 70 | const requestRange = visibleRangeRef.current; | |
| 69 | const requestRange = visibleRange; | |
| 71 | 70 | const engine: NativeReviewDiffHighlightEngine = "native"; |
| 72 | 71 | |
| 73 | 72 | void (async () => { |
| ... | ... | @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { |
| 119 | 118 | })(); |
| 120 | 119 | |
| 121 | 120 | 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]); | |
| 135 | 122 | |
| 136 | 123 | return { |
| 137 | 124 | tokensPatchJson, |
| 138 | updateVisibleRange, | |
| 125 | updateVisibleRange: scheduler.update, | |
| 139 | 126 | }; |
| 140 | 127 | } |
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts+2-2| ... | ... | @@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): { |
| 21 | 21 | ); |
| 22 | 22 | const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); |
| 23 | 23 | const nativeReviewDiffStyle = useMemo( |
| 24 | () => createNativeReviewDiffStyle(codeSurface), | |
| 25 | [codeSurface], | |
| 24 | () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak), | |
| 25 | [appearance.codeWordBreak, codeSurface], | |
| 26 | 26 | ); |
| 27 | 27 | |
| 28 | 28 | return { |