| 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,6 +8,10 @@ android { |
| 8 | namespace 'expo.modules.t3reviewdiff' | 8 | namespace 'expo.modules.t3reviewdiff' |
| 9 | compileSdk rootProject.ext.compileSdkVersion | 9 | compileSdk rootProject.ext.compileSdkVersion |
| 10 | 10 | ||
| 11 | testOptions { | ||
| 12 | unitTests.includeAndroidResources = true | ||
| 13 | } | ||
| 14 | |||
| 11 | defaultConfig { | 15 | defaultConfig { |
| 12 | minSdkVersion rootProject.ext.minSdkVersion | 16 | minSdkVersion rootProject.ext.minSdkVersion |
| 13 | targetSdkVersion rootProject.ext.targetSdkVersion | 17 | targetSdkVersion rootProject.ext.targetSdkVersion |
| ... | @@ -16,4 +20,12 @@ android { | ... | @@ -16,4 +20,12 @@ android { |
| 16 | 20 | ||
| 17 | dependencies { | 21 | dependencies { |
| 18 | implementation project(':expo-modules-core') | 22 | implementation project(':expo-modules-core') |
| 23 | testImplementation 'junit:junit:4.13.2' | ||
| 24 | testImplementation 'org.robolectric:robolectric:4.16.1' | ||
| 25 | } | ||
| 26 | |||
| 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,6 +9,7 @@ import android.graphics.Path |
| 9 | import android.graphics.RectF | 9 | import android.graphics.RectF |
| 10 | import android.graphics.Shader | 10 | import android.graphics.Shader |
| 11 | import android.graphics.Typeface | 11 | import android.graphics.Typeface |
| 12 | import android.text.TextPaint | ||
| 12 | import kotlin.math.max | 13 | import kotlin.math.max |
| 13 | import kotlin.math.min | 14 | import kotlin.math.min |
| 14 | 15 | ||
| ... | @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { | ... | @@ -177,6 +178,39 @@ internal class ReviewDiffCanvasDrawing(context: Context) { |
| 177 | textPaint.isUnderlineText = fontStyle and 4 != 0 | 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 | fun lineNumberColor(change: String): Int = when (change) { | 214 | fun lineNumberColor(change: String): Int = when (change) { |
| 181 | "add" -> theme.addText | 215 | "add" -> theme.addText |
| 182 | "delete" -> theme.deleteText | 216 | "delete" -> theme.deleteText |
| ... | @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { | ... | @@ -198,13 +232,17 @@ internal class ReviewDiffCanvasDrawing(context: Context) { |
| 198 | } | 232 | } |
| 199 | } | 233 | } |
| 200 | 234 | ||
| 235 | /** Highlights word diffs; [top]..[bottom] is the row's first visual line. */ | ||
| 236 | @Suppress("LongParameterList") | ||
| 201 | fun drawWordDiffRanges( | 237 | fun drawWordDiffRanges( |
| 202 | canvas: Canvas, | 238 | canvas: Canvas, |
| 203 | row: DiffRow, | 239 | row: DiffRow, |
| 204 | codeX: Float, | 240 | codeX: Float, |
| 205 | top: Int, | 241 | top: Int, |
| 206 | bottom: Int | 242 | bottom: Int, |
| 243 | lines: CodeLines | ||
| 207 | ) { | 244 | ) { |
| 245 | if (lines.nativeLayout != null) return | ||
| 208 | if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return | 246 | if (row.wordDiffRanges.isEmpty() || (row.change != "add" && row.change != "delete")) return |
| 209 | val color = if (row.change == "add") theme.addBar else theme.deleteBar | 247 | val color = if (row.change == "add") theme.addBar else theme.deleteBar |
| 210 | backgroundPaint.color = withAlpha(color, 71) | 248 | backgroundPaint.color = withAlpha(color, 71) |
| ... | @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { | ... | @@ -213,14 +251,66 @@ internal class ReviewDiffCanvasDrawing(context: Context) { |
| 213 | val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) | 251 | val highlightHeight = max(4f * density, min(bottom - top - 4f * density, fontHeight)) |
| 214 | val highlightTop = (top + bottom - highlightHeight) / 2f | 252 | val highlightTop = (top + bottom - highlightHeight) / 2f |
| 215 | row.wordDiffRanges.forEach { range -> | 253 | row.wordDiffRanges.forEach { range -> |
| 216 | val left = codeX + range.start * characterWidth | 254 | // A wrapped row splits the highlight at each visual line boundary. |
| 217 | val right = max(left + 2f * density, codeX + range.end * characterWidth) | 255 | lines.starts.forEachIndexed { line, lineStart -> |
| 218 | canvas.drawRoundRect( | 256 | val start = max(range.start, lineStart) |
| 219 | RectF(left, highlightTop, right, highlightTop + highlightHeight), | 257 | val end = min(range.end, lines.end(line, Int.MAX_VALUE)) |
| 220 | 3f * density, | 258 | if (end <= start) return@forEachIndexed |
| 221 | 3f * density, | 259 | val left = codeX + (start - lineStart) * characterWidth |
| 222 | backgroundPaint, | 260 | val right = max(left + 2f * density, left + (end - start) * characterWidth) |
| 223 | ) | 261 | val lineTop = highlightTop + line * lines.height |
| 262 | canvas.drawRoundRect( | ||
| 263 | RectF(left, lineTop, right, lineTop + highlightHeight), | ||
| 264 | 3f * density, | ||
| 265 | 3f * density, | ||
| 266 | backgroundPaint, | ||
| 267 | ) | ||
| 268 | } | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | /** Draws a code row's text, or its syntax [tokens] when present, one visual line per start. */ | ||
| 273 | @Suppress("LongParameterList") | ||
| 274 | fun drawCode( | ||
| 275 | canvas: Canvas, | ||
| 276 | content: String, | ||
| 277 | tokens: List<DiffToken>?, | ||
| 278 | codeX: Float, | ||
| 279 | baseline: Float, | ||
| 280 | style: DiffStyle, | ||
| 281 | lines: CodeLines | ||
| 282 | ) { | ||
| 283 | val nativeLayout = lines.nativeLayout | ||
| 284 | if (nativeLayout != null) { | ||
| 285 | canvas.save() | ||
| 286 | canvas.translate(codeX, baseline - nativeLayout.getLineBaseline(0)) | ||
| 287 | nativeLayout.draw(canvas) | ||
| 288 | canvas.restore() | ||
| 289 | return | ||
| 290 | } | ||
| 291 | val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens | ||
| 292 | var line = 0 | ||
| 293 | var x = codeX | ||
| 294 | var column = 0 | ||
| 295 | runs.forEach { run -> | ||
| 296 | configureCodePaint(run.color ?: theme.text, run.fontStyle, style) | ||
| 297 | var start = 0 | ||
| 298 | while (start < run.content.length) { | ||
| 299 | while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) { | ||
| 300 | line += 1 | ||
| 301 | x = codeX | ||
| 302 | } | ||
| 303 | val end = min(run.content.length, lines.end(line, Int.MAX_VALUE) - column) | ||
| 304 | val lineBaseline = baseline + line * lines.height | ||
| 305 | if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top && | ||
| 306 | lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom | ||
| 307 | ) { | ||
| 308 | canvas.drawText(run.content, start, end, x, lineBaseline, textPaint) | ||
| 309 | x += textPaint.measureText(run.content, start, end) | ||
| 310 | } | ||
| 311 | start = end | ||
| 312 | } | ||
| 313 | column += run.content.length | ||
| 224 | } | 314 | } |
| 225 | } | 315 | } |
| 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,10 +143,13 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont |
| 143 | fun setRowsJson(value: String) { | 143 | fun setRowsJson(value: String) { |
| 144 | rowsDecodeGeneration += 1 | 144 | rowsDecodeGeneration += 1 |
| 145 | val generation = rowsDecodeGeneration | 145 | val generation = rowsDecodeGeneration |
| 146 | val prepareLayout = canvasView.prepareRows() | ||
| 146 | payloadDecodeExecutor.execute { | 147 | payloadDecodeExecutor.execute { |
| 147 | val decodedRows = parseRows(value) | 148 | val decodedRows = parseRows(value) |
| 149 | val codeLayouts = prepareLayout(decodedRows) | ||
| 148 | post { | 150 | post { |
| 149 | if (generation != rowsDecodeGeneration) return@post | 151 | if (generation != rowsDecodeGeneration) return@post |
| 152 | canvasView.useCodeLayouts(codeLayouts) | ||
| 150 | rows = decodedRows | 153 | rows = decodedRows |
| 151 | lastVisibleFileId = null | 154 | lastVisibleFileId = null |
| 152 | rebuildVisibleRows() | 155 | rebuildVisibleRows() |
| ... | @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( | ... | @@ -460,7 +463,7 @@ internal data class DiffWordDiffRange( |
| 460 | val end: Int | 463 | val end: Int |
| 461 | ) | 464 | ) |
| 462 | 465 | ||
| 463 | private data class DiffToken( | 466 | internal data class DiffToken( |
| 464 | val content: String, | 467 | val content: String, |
| 465 | val color: Int?, | 468 | val color: Int?, |
| 466 | val fontStyle: Int | 469 | val fontStyle: Int |
| ... | @@ -543,6 +546,7 @@ internal data class DiffTheme( | ... | @@ -543,6 +546,7 @@ internal data class DiffTheme( |
| 543 | } | 546 | } |
| 544 | 547 | ||
| 545 | internal data class DiffStyle( | 548 | internal data class DiffStyle( |
| 549 | val wordWrap: Boolean, | ||
| 546 | val rowHeightPx: Float, | 550 | val rowHeightPx: Float, |
| 547 | val gutterWidthPx: Float, | 551 | val gutterWidthPx: Float, |
| 548 | val codePaddingPx: Float, | 552 | val codePaddingPx: Float, |
| ... | @@ -564,6 +568,7 @@ internal data class DiffStyle( | ... | @@ -564,6 +568,7 @@ internal data class DiffStyle( |
| 564 | ) { | 568 | ) { |
| 565 | companion object { | 569 | companion object { |
| 566 | fun defaults(density: Float): DiffStyle = DiffStyle( | 570 | fun defaults(density: Float): DiffStyle = DiffStyle( |
| 571 | wordWrap = false, | ||
| 567 | rowHeightPx = 20f * density, | 572 | rowHeightPx = 20f * density, |
| 568 | gutterWidthPx = 72f * density, | 573 | gutterWidthPx = 72f * density, |
| 569 | codePaddingPx = 10f * density, | 574 | codePaddingPx = 10f * density, |
| ... | @@ -587,6 +592,7 @@ internal data class DiffStyle( | ... | @@ -587,6 +592,7 @@ internal data class DiffStyle( |
| 587 | fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { | 592 | fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { |
| 588 | val json = JSONObject(value) | 593 | val json = JSONObject(value) |
| 589 | DiffStyle( | 594 | DiffStyle( |
| 595 | wordWrap = json.optBoolean("wordWrap", fallback.wordWrap), | ||
| 590 | rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), | 596 | rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), |
| 591 | gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), | 597 | gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), |
| 592 | codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), | 598 | codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), |
| ... | @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -684,6 +690,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 684 | }, | 690 | }, |
| 685 | ) | 691 | ) |
| 686 | private var rowOffsets = intArrayOf(0) | 692 | private var rowOffsets = intArrayOf(0) |
| 693 | |||
| 694 | private var codeWrap = CodeWrapLayout.NONE | ||
| 687 | private var verticalOffset = 0 | 695 | private var verticalOffset = 0 |
| 688 | private var horizontalOffset = 0 | 696 | private var horizontalOffset = 0 |
| 689 | private val headerPathOffsetsByFileId = mutableMapOf<String, Int>() | 697 | private val headerPathOffsetsByFileId = mutableMapOf<String, Int>() |
| ... | @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -699,6 +707,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 699 | var tokensByRowId: Map<String, List<DiffToken>> = emptyMap() | 707 | var tokensByRowId: Map<String, List<DiffToken>> = emptyMap() |
| 700 | set(value) { | 708 | set(value) { |
| 701 | field = value | 709 | field = value |
| 710 | if (style.wordWrap) rebuildOffsets() | ||
| 702 | invalidate() | 711 | invalidate() |
| 703 | } | 712 | } |
| 704 | var viewedFileIds: Set<String> = emptySet() | 713 | var viewedFileIds: Set<String> = emptySet() |
| ... | @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -725,6 +734,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 725 | set(value) { | 734 | set(value) { |
| 726 | field = value | 735 | field = value |
| 727 | drawing.theme = value | 736 | drawing.theme = value |
| 737 | if (style.wordWrap) rebuildOffsets() | ||
| 728 | invalidate() | 738 | invalidate() |
| 729 | } | 739 | } |
| 730 | var style: DiffStyle = DiffStyle.defaults(density) | 740 | var style: DiffStyle = DiffStyle.defaults(density) |
| ... | @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -742,6 +752,11 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 742 | var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null | 752 | var onRowTap: ((DiffRow, String, RowTapTarget) -> Unit)? = null |
| 743 | var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null | 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 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { | 760 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { |
| 746 | setMeasuredDimension( | 761 | setMeasuredDimension( |
| 747 | MeasureSpec.getSize(widthMeasureSpec), | 762 | MeasureSpec.getSize(widthMeasureSpec), |
| ... | @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -751,6 +766,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 751 | 766 | ||
| 752 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { | 767 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { |
| 753 | super.onSizeChanged(width, height, oldWidth, oldHeight) | 768 | super.onSizeChanged(width, height, oldWidth, oldHeight) |
| 769 | // Wrapped rows take their height from the width, so a new width is a new layout. | ||
| 770 | if (style.wordWrap && width != oldWidth) layoutRows() | ||
| 754 | setVerticalOffset(verticalOffset) | 771 | setVerticalOffset(verticalOffset) |
| 755 | setHorizontalOffset(horizontalOffset) | 772 | setHorizontalOffset(horizontalOffset) |
| 756 | clampHeaderPathOffsets() | 773 | clampHeaderPathOffsets() |
| ... | @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -818,7 +835,8 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 818 | 835 | ||
| 819 | fun horizontalOffset(): Int = horizontalOffset | 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 | fun maxHorizontalOffset(target: HorizontalPanTarget): Int = | 841 | fun maxHorizontalOffset(target: HorizontalPanTarget): Int = |
| 824 | if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { | 842 | if (target.kind == HorizontalPanKind.FILE_HEADER_PATH) { |
| ... | @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -844,14 +862,20 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 844 | } | 862 | } |
| 845 | 863 | ||
| 846 | private fun rebuildOffsets() { | 864 | private fun rebuildOffsets() { |
| 865 | layoutRows() | ||
| 866 | requestLayout() | ||
| 867 | invalidate() | ||
| 868 | } | ||
| 869 | |||
| 870 | private fun layoutRows() { | ||
| 871 | codeWrap = drawing.codeWrapLayout(rows, tokensByRowId, style, width) | ||
| 847 | rowOffsets = IntArray(rows.size + 1) | 872 | rowOffsets = IntArray(rows.size + 1) |
| 848 | rows.forEachIndexed { index, row -> | 873 | rows.forEachIndexed { index, row -> |
| 849 | rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) | 874 | rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) |
| 850 | } | 875 | } |
| 851 | setVerticalOffset(verticalOffset) | 876 | setVerticalOffset(verticalOffset) |
| 877 | setHorizontalOffset(horizontalOffset) | ||
| 852 | clampHeaderPathOffsets() | 878 | clampHeaderPathOffsets() |
| 853 | requestLayout() | ||
| 854 | invalidate() | ||
| 855 | } | 879 | } |
| 856 | 880 | ||
| 857 | private fun rowHeight(row: DiffRow): Int = when (row.kind) { | 881 | private fun rowHeight(row: DiffRow): Int = when (row.kind) { |
| ... | @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -862,6 +886,7 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 862 | } else { | 886 | } else { |
| 863 | (124 * density).toInt() | 887 | (124 * density).toInt() |
| 864 | } | 888 | } |
| 889 | "line" -> codeWrap.rowHeight(row.id, style.rowHeightPx.toInt()) | ||
| 865 | else -> style.rowHeightPx.toInt() | 890 | else -> style.rowHeightPx.toInt() |
| 866 | }.coerceAtLeast(1) | 891 | }.coerceAtLeast(1) |
| 867 | 892 | ||
| ... | @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { | ... | @@ -1191,23 +1216,17 @@ private class DiffCanvasView(context: Context) : View(context) { |
| 1191 | ) | 1216 | ) |
| 1192 | } | 1217 | } |
| 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 | drawScrollableCode(canvas, top, bottom) { codeX -> | 1222 | drawScrollableCode(canvas, top, bottom) { codeX -> |
| 1196 | drawing.configureCodePaint(theme.text, 0, style) | 1223 | drawing.configureCodePaint(theme.text, 0, style) |
| 1197 | drawing.drawWordDiffRanges(canvas, row, codeX, top, bottom) | 1224 | drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines) |
| 1198 | if (tokens.isNullOrEmpty()) { | 1225 | val baseline = lines.baseline(top, firstLineBottom, textPaint) |
| 1199 | canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) | 1226 | drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines) |
| 1200 | } else { | ||
| 1201 | var x = codeX | ||
| 1202 | tokens.forEach { token -> | ||
| 1203 | drawing.configureCodePaint(token.color ?: theme.text, token.fontStyle, style) | ||
| 1204 | canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) | ||
| 1205 | x += textPaint.measureText(token.content) | ||
| 1206 | } | ||
| 1207 | } | ||
| 1208 | } | 1227 | } |
| 1209 | 1228 | ||
| 1210 | drawLineNumber(canvas, row, top, bottom) | 1229 | drawLineNumber(canvas, row, top, firstLineBottom) |
| 1211 | } | 1230 | } |
| 1212 | 1231 | ||
| 1213 | private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { | 1232 | private fun drawLineNumber(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { |
apps/mobile/modules/t3-review-diff/android/src/test/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayoutTest.kt created+150| ... | @@ -0,0 +1,150 @@ | ||
| 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,6 +137,7 @@ private struct ReviewDiffNativeTheme { |
| 137 | } | 137 | } |
| 138 | 138 | ||
| 139 | private struct ReviewDiffNativeStylePayload: Decodable { | 139 | private struct ReviewDiffNativeStylePayload: Decodable { |
| 140 | let wordWrap: Bool? | ||
| 140 | let rowHeight: Double? | 141 | let rowHeight: Double? |
| 141 | let contentWidth: Double? | 142 | let contentWidth: Double? |
| 142 | let changeBarWidth: Double? | 143 | let changeBarWidth: Double? |
| ... | @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { | ... | @@ -170,6 +171,7 @@ private struct ReviewDiffNativeStylePayload: Decodable { |
| 170 | } | 171 | } |
| 171 | 172 | ||
| 172 | private struct ReviewDiffNativeStyle { | 173 | private struct ReviewDiffNativeStyle { |
| 174 | let wordWrap: Bool | ||
| 173 | let rowHeight: CGFloat | 175 | let rowHeight: CGFloat |
| 174 | let contentWidth: CGFloat | 176 | let contentWidth: CGFloat |
| 175 | let changeBarWidth: CGFloat | 177 | let changeBarWidth: CGFloat |
| ... | @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { | ... | @@ -203,6 +205,7 @@ private struct ReviewDiffNativeStyle { |
| 203 | 205 | ||
| 204 | static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { | 206 | static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle { |
| 205 | ReviewDiffNativeStyle( | 207 | ReviewDiffNativeStyle( |
| 208 | wordWrap: payload?.wordWrap ?? false, | ||
| 206 | rowHeight: metric(payload?.rowHeight, fallback: 24), | 209 | rowHeight: metric(payload?.rowHeight, fallback: 24), |
| 207 | contentWidth: metric(payload?.contentWidth, fallback: 2800), | 210 | contentWidth: metric(payload?.contentWidth, fallback: 2800), |
| 208 | changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), | 211 | changeBarWidth: nonNegativeMetric(payload?.changeBarWidth, fallback: 4), |
| ... | @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { | ... | @@ -277,6 +280,7 @@ private struct ReviewDiffNativeStyle { |
| 277 | 280 | ||
| 278 | func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { | 281 | func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle { |
| 279 | ReviewDiffNativeStyle( | 282 | ReviewDiffNativeStyle( |
| 283 | wordWrap: wordWrap, | ||
| 280 | rowHeight: rowHeight ?? self.rowHeight, | 284 | rowHeight: rowHeight ?? self.rowHeight, |
| 281 | contentWidth: contentWidth ?? self.contentWidth, | 285 | contentWidth: contentWidth ?? self.contentWidth, |
| 282 | changeBarWidth: changeBarWidth, | 286 | changeBarWidth: changeBarWidth, |
| ... | @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -434,16 +438,21 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 434 | guard let self, generation == self.rowsDecodeGeneration else { | 438 | guard let self, generation == self.rowsDecodeGeneration else { |
| 435 | return | 439 | return |
| 436 | } | 440 | } |
| 437 | self.rows = decodedRows | 441 | self.contentView.prepareRows(decodedRows, on: self.payloadDecodeQueue, isCurrent: { [weak self] in |
| 438 | self.contentView.rows = decodedRows | 442 | generation == self?.rowsDecodeGeneration |
| 439 | self.hasAppliedInitialRowIndex = false | 443 | }, completion: { [weak self] in |
| 440 | self.lastVisibleFileId = nil | 444 | guard let self, generation == self.rowsDecodeGeneration else { return } |
| 441 | self.emitDebug("rows-decoded", [ | 445 | self.rows = decodedRows |
| 442 | "rows": decodedRows.count, | 446 | self.contentView.rows = decodedRows |
| 443 | "firstKind": decodedRows.first?.kind ?? "none", | 447 | self.hasAppliedInitialRowIndex = false |
| 444 | ]) | 448 | self.lastVisibleFileId = nil |
| 445 | self.updateContentMetrics() | 449 | self.emitDebug("rows-decoded", [ |
| 446 | self.applyPendingScrollIfNeeded() | 450 | "rows": decodedRows.count, |
| 451 | "firstKind": decodedRows.first?.kind ?? "none", | ||
| 452 | ]) | ||
| 453 | self.updateContentMetrics() | ||
| 454 | self.applyPendingScrollIfNeeded() | ||
| 455 | }) | ||
| 447 | } | 456 | } |
| 448 | } catch { | 457 | } catch { |
| 449 | let message = error.localizedDescription | 458 | let message = error.localizedDescription |
| ... | @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -652,6 +661,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 652 | 661 | ||
| 653 | private func updateContentMetrics() { | 662 | private func updateContentMetrics() { |
| 654 | let style = contentView.style | 663 | let style = contentView.style |
| 664 | contentView.viewportWidth = bounds.width | ||
| 655 | let height = max(bounds.height, contentView.contentHeight) | 665 | let height = max(bounds.height, contentView.contentHeight) |
| 656 | let width = bounds.width | 666 | let width = bounds.width |
| 657 | scrollView.contentSize = CGSize(width: bounds.width, height: height) | 667 | scrollView.contentSize = CGSize(width: bounds.width, height: height) |
| ... | @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { | ... | @@ -661,7 +671,6 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { |
| 661 | width: max(width, 1), | 671 | width: max(width, 1), |
| 662 | height: max(bounds.height, 1) | 672 | height: max(bounds.height, 1) |
| 663 | ) | 673 | ) |
| 664 | contentView.viewportWidth = bounds.width | ||
| 665 | contentView.verticalOffset = scrollView.contentOffset.y | 674 | contentView.verticalOffset = scrollView.contentOffset.y |
| 666 | contentView.invalidateVisibleViewport() | 675 | contentView.invalidateVisibleViewport() |
| 667 | contentView.setNeedsDisplay() | 676 | contentView.setNeedsDisplay() |
| ... | @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -929,6 +938,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 929 | headerPathOffsetsByFileId.removeAll() | 938 | headerPathOffsetsByFileId.removeAll() |
| 930 | activePanFileId = nil | 939 | activePanFileId = nil |
| 931 | activePanKind = nil | 940 | activePanKind = nil |
| 941 | codeDecorationVersion += 1 | ||
| 932 | tokenAttributedStringsByRowId.removeAll() | 942 | tokenAttributedStringsByRowId.removeAll() |
| 933 | rebuildRowLayout() | 943 | rebuildRowLayout() |
| 934 | setNeedsDisplayForVisibleBounds() | 944 | setNeedsDisplayForVisibleBounds() |
| ... | @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -936,6 +946,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 936 | } | 946 | } |
| 937 | var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { | 947 | var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] { |
| 938 | didSet { | 948 | didSet { |
| 949 | codeDecorationVersion += 1 | ||
| 939 | tokenAttributedStringsByRowId.removeAll() | 950 | tokenAttributedStringsByRowId.removeAll() |
| 940 | clampHorizontalOffsets() | 951 | clampHorizontalOffsets() |
| 941 | setNeedsDisplayForVisibleBounds() | 952 | setNeedsDisplayForVisibleBounds() |
| ... | @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -976,6 +987,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 976 | } | 987 | } |
| 977 | var style = ReviewDiffNativeStyle.resolve(nil) { | 988 | var style = ReviewDiffNativeStyle.resolve(nil) { |
| 978 | didSet { | 989 | didSet { |
| 990 | codeDecorationVersion += 1 | ||
| 979 | tokenAttributedStringsByRowId.removeAll() | 991 | tokenAttributedStringsByRowId.removeAll() |
| 980 | rebuildRowLayout() | 992 | rebuildRowLayout() |
| 981 | clampHorizontalOffsets() | 993 | clampHorizontalOffsets() |
| ... | @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -984,6 +996,10 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 984 | } | 996 | } |
| 985 | var viewportWidth: CGFloat = 0 { | 997 | var viewportWidth: CGFloat = 0 { |
| 986 | didSet { | 998 | didSet { |
| 999 | // Wrapped rows take their height from the width, so a new width is a new layout. | ||
| 1000 | if style.wordWrap, viewportWidth != oldValue { | ||
| 1001 | rebuildRowLayout() | ||
| 1002 | } | ||
| 987 | clampHorizontalOffsets() | 1003 | clampHorizontalOffsets() |
| 988 | setNeedsDisplayForVisibleBounds() | 1004 | setNeedsDisplayForVisibleBounds() |
| 989 | } | 1005 | } |
| ... | @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -992,6 +1008,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 992 | var theme = ReviewDiffNativeTheme.resolve("light") { | 1008 | var theme = ReviewDiffNativeTheme.resolve("light") { |
| 993 | didSet { | 1009 | didSet { |
| 994 | tokenColorsByHex.removeAll() | 1010 | tokenColorsByHex.removeAll() |
| 1011 | codeDecorationVersion += 1 | ||
| 995 | tokenAttributedStringsByRowId.removeAll() | 1012 | tokenAttributedStringsByRowId.removeAll() |
| 996 | setNeedsDisplayForVisibleBounds() | 1013 | setNeedsDisplayForVisibleBounds() |
| 997 | } | 1014 | } |
| ... | @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1004,6 +1021,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1004 | private var tokenColorsByHex: [String: UIColor] = [:] | 1021 | private var tokenColorsByHex: [String: UIColor] = [:] |
| 1005 | private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] | 1022 | private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:] |
| 1006 | private var codeCharacterWidth: CGFloat = 8 | 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 | private var panStartHorizontalOffset: CGFloat = 0 | 1031 | private var panStartHorizontalOffset: CGFloat = 0 |
| 1008 | private var activePanFileId: String? | 1032 | private var activePanFileId: String? |
| 1009 | private var activePanKind: ReviewDiffHorizontalPanKind? | 1033 | private var activePanKind: ReviewDiffHorizontalPanKind? |
| ... | @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1029,6 +1053,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1029 | stickyWidth + style.codePadding | 1053 | stickyWidth + style.codePadding |
| 1030 | } | 1054 | } |
| 1031 | 1055 | ||
| 1056 | /// Height before word wrap. Laid-out rows use height(at:), which includes wrapped lines. | ||
| 1032 | private func height(for row: ReviewDiffNativeRow) -> CGFloat { | 1057 | private func height(for row: ReviewDiffNativeRow) -> CGFloat { |
| 1033 | if row.kind == "file" { | 1058 | if row.kind == "file" { |
| 1034 | return style.fileHeaderHeight | 1059 | return style.fileHeaderHeight |
| ... | @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1045,6 +1070,16 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1045 | return style.rowHeight | 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 | func frameForRow(at index: Int) -> CGRect? { | 1083 | func frameForRow(at index: Int) -> CGRect? { |
| 1049 | guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { | 1084 | guard rows.indices.contains(index), rowOffsets.indices.contains(index) else { |
| 1050 | return nil | 1085 | return nil |
| ... | @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1054,43 +1089,110 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1054 | x: 0, | 1089 | x: 0, |
| 1055 | y: rowOffsets[index], | 1090 | y: rowOffsets[index], |
| 1056 | width: max(viewportWidth, 1), | 1091 | width: max(viewportWidth, 1), |
| 1057 | height: height(for: rows[index]) | 1092 | height: height(at: index) |
| 1058 | ) | 1093 | ) |
| 1059 | } | 1094 | } |
| 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 | private func rebuildRowLayout() { | 1135 | private func rebuildRowLayout() { |
| 1062 | var nextOffsets: [CGFloat] = [] | 1136 | var nextOffsets: [CGFloat] = [] |
| 1063 | var nextFileHeaderRowIndices: [Int] = [] | 1137 | var nextFileHeaderRowIndices: [Int] = [] |
| 1064 | nextOffsets.reserveCapacity(rows.count) | 1138 | nextOffsets.reserveCapacity(rows.count) |
| 1065 | var maxColumnCountsByFileId: [String: Int] = [:] | 1139 | var maxColumnCountsByFileId: [String: Int] = [:] |
| 1140 | var nextCodeLayouts: [String: ReviewDiffCodeLayout] = [:] | ||
| 1066 | var offset: CGFloat = 0 | 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 | for (index, row) in rows.enumerated() { | 1154 | for (index, row) in rows.enumerated() { |
| 1069 | nextOffsets.append(offset) | 1155 | nextOffsets.append(offset) |
| 1070 | if row.kind == "file" { | 1156 | if row.kind == "file" { |
| 1071 | nextFileHeaderRowIndices.append(index) | 1157 | nextFileHeaderRowIndices.append(index) |
| 1072 | } | 1158 | } |
| 1073 | offset += height(for: row) | 1159 | var rowHeight = height(for: row) |
| 1074 | 1160 | ||
| 1075 | let fileId = resolvedFileId(for: row) | 1161 | let fileId = resolvedFileId(for: row) |
| 1076 | switch row.kind { | 1162 | switch row.kind { |
| 1077 | case "line": | 1163 | case "line": |
| 1078 | maxColumnCountsByFileId[fileId] = max( | 1164 | // UTF-16 columns match the word diff ranges and the segments drawCodeLines draws. |
| 1079 | maxColumnCountsByFileId[fileId] ?? 0, | 1165 | let columnCount = row.content?.utf16.count ?? 0 |
| 1080 | row.content?.count ?? 0 | 1166 | maxColumnCountsByFileId[fileId] = max(maxColumnCountsByFileId[fileId] ?? 0, columnCount) |
| 1081 | ) | 1167 | if wrapColumns != nil, let content = row.content { |
| 1168 | let cached = codeLayoutsByRowId[row.id] | ||
| 1169 | let layout: ReviewDiffCodeLayout | ||
| 1170 | if let cached, cached.text == content { | ||
| 1171 | layout = cached | ||
| 1172 | } else { | ||
| 1173 | layout = ReviewDiffCodeLayout( | ||
| 1174 | text: content, font: font, width: wrapAvailableWidth, characterWidth: characterWidth | ||
| 1175 | ) | ||
| 1176 | } | ||
| 1177 | nextCodeLayouts[row.id] = layout | ||
| 1178 | if rowHeight > 0 { | ||
| 1179 | rowHeight = max(rowHeight, layout.firstLineHeight) + layout.extraHeight | ||
| 1180 | } | ||
| 1181 | } | ||
| 1082 | case "hunk": | 1182 | case "hunk": |
| 1083 | maxColumnCountsByFileId[fileId] = max( | 1183 | maxColumnCountsByFileId[fileId] = max( |
| 1084 | maxColumnCountsByFileId[fileId] ?? 0, | 1184 | maxColumnCountsByFileId[fileId] ?? 0, |
| 1085 | row.text?.count ?? 0 | 1185 | row.text?.utf16.count ?? 0 |
| 1086 | ) | 1186 | ) |
| 1087 | default: | 1187 | default: |
| 1088 | continue | 1188 | break |
| 1089 | } | 1189 | } |
| 1190 | offset += rowHeight | ||
| 1090 | } | 1191 | } |
| 1091 | 1192 | ||
| 1092 | let characterWidth = monospaceCharacterWidth(font: codeFont) | ||
| 1093 | codeCharacterWidth = characterWidth | 1193 | codeCharacterWidth = characterWidth |
| 1194 | codeWrapColumns = wrapColumns | ||
| 1195 | codeLayoutsByRowId = nextCodeLayouts | ||
| 1094 | contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in | 1196 | contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in |
| 1095 | let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 | 1197 | let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2 |
| 1096 | return max(0, min(style.contentWidth, measuredWidth)) | 1198 | return max(0, min(style.contentWidth, measuredWidth)) |
| ... | @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1498,7 +1600,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1498 | while lowerBound <= upperBound { | 1600 | while lowerBound <= upperBound { |
| 1499 | let midpoint = (lowerBound + upperBound) / 2 | 1601 | let midpoint = (lowerBound + upperBound) / 2 |
| 1500 | let rowStart = rowOffsets[midpoint] | 1602 | let rowStart = rowOffsets[midpoint] |
| 1501 | let rowEnd = rowStart + height(for: rows[midpoint]) | 1603 | let rowEnd = rowStart + height(at: midpoint) |
| 1502 | 1604 | ||
| 1503 | if absoluteY < rowStart { | 1605 | if absoluteY < rowStart { |
| 1504 | upperBound = midpoint - 1 | 1606 | upperBound = midpoint - 1 |
| ... | @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1521,7 +1623,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1521 | var upperBound = rows.count | 1623 | var upperBound = rows.count |
| 1522 | while lowerBound < upperBound { | 1624 | while lowerBound < upperBound { |
| 1523 | let midpoint = (lowerBound + upperBound) / 2 | 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 | if rowEnd < absoluteY { | 1628 | if rowEnd < absoluteY { |
| 1527 | lowerBound = midpoint + 1 | 1629 | lowerBound = midpoint + 1 |
| ... | @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1609,6 +1711,9 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1609 | let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { | 1711 | let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) { |
| 1610 | return maxHeaderPathOffset(for: row) | 1712 | return maxHeaderPathOffset(for: row) |
| 1611 | } | 1713 | } |
| 1714 | if codeWrapColumns != nil { | ||
| 1715 | return 0 | ||
| 1716 | } | ||
| 1612 | 1717 | ||
| 1613 | return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) | 1718 | return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX)) |
| 1614 | } | 1719 | } |
| ... | @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1728,7 +1833,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1728 | var drawnRowCount = 0 | 1833 | var drawnRowCount = 0 |
| 1729 | for rowIndex in firstRowIndex...lastRowIndex { | 1834 | for rowIndex in firstRowIndex...lastRowIndex { |
| 1730 | let rowStart = rowOffsets[rowIndex] | 1835 | let rowStart = rowOffsets[rowIndex] |
| 1731 | let rowHeight = height(for: rows[rowIndex]) | 1836 | let rowHeight = height(at: rowIndex) |
| 1732 | if rowHeight <= 0 { | 1837 | if rowHeight <= 0 { |
| 1733 | continue | 1838 | continue |
| 1734 | } | 1839 | } |
| ... | @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -1786,7 +1891,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 1786 | 1891 | ||
| 1787 | private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { | 1892 | private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) { |
| 1788 | let rowY = rowOffsets[rowIndex] - verticalOffset | 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 | switch row.kind { | 1896 | switch row.kind { |
| 1792 | case "file": | 1897 | case "file": |
| ... | @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2172,15 +2277,21 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2172 | let horizontalOffset = horizontalOffset(for: fileId) | 2277 | let horizontalOffset = horizontalOffset(for: fileId) |
| 2173 | let contentWidth = contentWidth(for: fileId) | 2278 | let contentWidth = contentWidth(for: fileId) |
| 2174 | let change = row.change ?? "context" | 2279 | let change = row.change ?? "context" |
| 2280 | // Wrapped rows keep the line number and first code line in the first row-height band. | ||
| 2281 | let layout = codeLayoutsByRowId[row.id] | ||
| 2282 | let firstLineRect = CGRect( | ||
| 2283 | x: rect.minX, y: rect.minY, width: rect.width, | ||
| 2284 | height: max(style.rowHeight, layout?.firstLineHeight ?? 0) | ||
| 2285 | ) | ||
| 2175 | rowBackground(for: change).setFill() | 2286 | rowBackground(for: change).setFill() |
| 2176 | context.fill(rect) | 2287 | context.fill(rect) |
| 2177 | 2288 | ||
| 2178 | if change == "add" { | 2289 | if change == "add" { |
| 2179 | theme.addBar.setFill() | 2290 | theme.addBar.setFill() |
| 2180 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight)) | 2291 | context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height)) |
| 2181 | } else if change == "delete" { | 2292 | } else if change == "delete" { |
| 2182 | drawDeleteStripes( | 2293 | drawDeleteStripes( |
| 2183 | rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight), | 2294 | rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height), |
| 2184 | context: context | 2295 | context: context |
| 2185 | ) | 2296 | ) |
| 2186 | } | 2297 | } |
| ... | @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2193,7 +2304,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2193 | "\(lineNumber)", | 2304 | "\(lineNumber)", |
| 2194 | rect: CGRect( | 2305 | rect: CGRect( |
| 2195 | x: style.changeBarWidth, | 2306 | x: style.changeBarWidth, |
| 2196 | y: centeredTextY(in: rect, font: lineNumberFont), | 2307 | y: centeredTextY(in: firstLineRect, font: lineNumberFont), |
| 2197 | width: style.gutterWidth - style.codePadding, | 2308 | width: style.gutterWidth - style.codePadding, |
| 2198 | height: lineNumberFont.lineHeight | 2309 | height: lineNumberFont.lineHeight |
| 2199 | ), | 2310 | ), |
| ... | @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2203,28 +2314,85 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2203 | } | 2314 | } |
| 2204 | 2315 | ||
| 2205 | context.saveGState() | 2316 | context.saveGState() |
| 2206 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight)) | 2317 | context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: rect.height)) |
| 2207 | let codeTextRect = CGRect( | 2318 | let codeTextRect = CGRect( |
| 2208 | x: codeStartX - horizontalOffset, | 2319 | x: codeStartX - horizontalOffset, |
| 2209 | y: centeredTextY(in: rect, font: codeFont), | 2320 | y: centeredTextY(in: firstLineRect, font: codeFont), |
| 2210 | width: contentWidth, | 2321 | width: contentWidth, |
| 2211 | height: codeFont.lineHeight | 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 | if let tokens = tokensByRowId[row.id], !tokens.isEmpty { | 2352 | if let tokens = tokensByRowId[row.id], !tokens.isEmpty { |
| 2215 | drawTokenText( | 2353 | let attributedText = tokenAttributedString( |
| 2216 | rowId: row.id, | 2354 | rowId: row.id, |
| 2217 | tokens, | 2355 | tokens: tokens, |
| 2218 | rect: codeTextRect, | ||
| 2219 | fallbackColor: theme.text, | 2356 | fallbackColor: theme.text, |
| 2220 | font: codeFont | 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 | } else { | 2365 | } else { |
| 2223 | drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont) | 2366 | let content = (row.content ?? "") as NSString |
| 2367 | drawCodeLines(length: content.length, lineStarts: lineStarts, firstLineRect: codeTextRect) { range, lineRect in | ||
| 2368 | let segment = range.length == content.length ? content as String : content.substring(with: range) | ||
| 2369 | drawText(segment, rect: lineRect, color: theme.text, font: codeFont) | ||
| 2370 | } | ||
| 2224 | } | 2371 | } |
| 2225 | context.restoreGState() | 2372 | context.restoreGState() |
| 2226 | } | 2373 | } |
| 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 | private func drawSelectionOverlay( | 2396 | private func drawSelectionOverlay( |
| 2229 | _ row: ReviewDiffNativeRow, | 2397 | _ row: ReviewDiffNativeRow, |
| 2230 | rect: CGRect, | 2398 | rect: CGRect, |
| ... | @@ -2244,7 +2412,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2244,7 +2412,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2244 | 2412 | ||
| 2245 | private func drawWordDiffRanges( | 2413 | private func drawWordDiffRanges( |
| 2246 | _ row: ReviewDiffNativeRow, | 2414 | _ row: ReviewDiffNativeRow, |
| 2247 | rowRect: CGRect, | 2415 | lineStarts: [Int], |
| 2416 | firstLineRect: CGRect, | ||
| 2248 | context: CGContext, | 2417 | context: CGContext, |
| 2249 | horizontalOffset: CGFloat | 2418 | horizontalOffset: CGFloat |
| 2250 | ) { | 2419 | ) { |
| ... | @@ -2263,8 +2432,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2263,8 +2432,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2263 | } else { | 2432 | } else { |
| 2264 | fillColor = theme.deleteBar.withAlphaComponent(0.28) | 2433 | fillColor = theme.deleteBar.withAlphaComponent(0.28) |
| 2265 | } | 2434 | } |
| 2266 | let highlightHeight = max(4, min(rowRect.height - 4, codeFont.lineHeight)) | 2435 | let highlightHeight = max(4, min(firstLineRect.height - 4, codeFont.lineHeight)) |
| 2267 | let highlightY = rowRect.midY - highlightHeight / 2 | 2436 | let highlightY = firstLineRect.midY - highlightHeight / 2 |
| 2268 | 2437 | ||
| 2269 | fillColor.setFill() | 2438 | fillColor.setFill() |
| 2270 | for range in ranges { | 2439 | for range in ranges { |
| ... | @@ -2272,15 +2441,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2272,15 +2441,22 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2272 | continue | 2441 | continue |
| 2273 | } | 2442 | } |
| 2274 | 2443 | ||
| 2275 | let startX = codeStartX - horizontalOffset + CGFloat(range.start) * codeCharacterWidth | 2444 | // A wrapped row splits the highlight at each visual line boundary. |
| 2276 | let width = max(2, CGFloat(range.end - range.start) * codeCharacterWidth) | 2445 | for (line, lineStart) in lineStarts.enumerated() { |
| 2277 | let highlightRect = CGRect( | 2446 | let lineEnd = line + 1 < lineStarts.count ? lineStarts[line + 1] : Int.max |
| 2278 | x: startX, | 2447 | let start = max(range.start, lineStart) |
| 2279 | y: highlightY, | 2448 | let end = min(range.end, lineEnd) |
| 2280 | width: width, | 2449 | guard end > start else { |
| 2281 | height: highlightHeight | 2450 | continue |
| 2282 | ) | 2451 | } |
| 2283 | UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() | 2452 | let highlightRect = CGRect( |
| 2453 | x: codeStartX - horizontalOffset + CGFloat(start - lineStart) * codeCharacterWidth, | ||
| 2454 | y: highlightY + CGFloat(line) * codeWrapLineHeight, | ||
| 2455 | width: max(2, CGFloat(end - start) * codeCharacterWidth), | ||
| 2456 | height: highlightHeight | ||
| 2457 | ) | ||
| 2458 | UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill() | ||
| 2459 | } | ||
| 2284 | } | 2460 | } |
| 2285 | } | 2461 | } |
| 2286 | 2462 | ||
| ... | @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { | ... | @@ -2448,22 +2624,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { |
| 2448 | return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) | 2624 | return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength) |
| 2449 | } | 2625 | } |
| 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 | private func tokenAttributedString( | 2627 | private func tokenAttributedString( |
| 2468 | rowId: String, | 2628 | rowId: String, |
| 2469 | tokens: [ReviewDiffNativeToken], | 2629 | tokens: [ReviewDiffNativeToken], |
apps/mobile/modules/t3-review-diff/tests/ios/main.swift created+60| ... | @@ -0,0 +1,60 @@ | ||
| 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,8 +110,9 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { |
| 110 | () => JSON.stringify(nativeReviewDiffTheme), | 110 | () => JSON.stringify(nativeReviewDiffTheme), |
| 111 | [nativeReviewDiffTheme], | 111 | [nativeReviewDiffTheme], |
| 112 | ); | 112 | ); |
| 113 | // The card's height is sized from its row count, so its snippet stays unwrapped. | ||
| 113 | const nativeStyleJson = useMemo( | 114 | const nativeStyleJson = useMemo( |
| 114 | () => JSON.stringify(nativeReviewDiffStyle), | 115 | () => JSON.stringify({ ...nativeReviewDiffStyle, wordWrap: false }), |
| 115 | [nativeReviewDiffStyle], | 116 | [nativeReviewDiffStyle], |
| 116 | ); | 117 | ); |
| 117 | const nativeDiffHeight = useMemo( | 118 | const nativeDiffHeight = useMemo( |
apps/mobile/src/features/review/nativeReviewDiffAdapter.ts+6-1| ... | @@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string { | ... | @@ -63,8 +63,13 @@ function opaqueNativeHexColor(color: string, background: string): string { |
| 63 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; | 63 | return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; |
| 64 | } | 64 | } |
| 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 | return { | 71 | return { |
| 72 | wordWrap, | ||
| 68 | rowHeight: codeSurface.rowHeight, | 73 | rowHeight: codeSurface.rowHeight, |
| 69 | contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, | 74 | contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, |
| 70 | changeBarWidth: 4, | 75 | changeBarWidth: 4, |
apps/mobile/src/features/review/reviewDiffHighlightScheduler.test.ts created+74| ... | @@ -0,0 +1,74 @@ | ||
| 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,4 +1,4 @@ |
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | highlightNativeReviewDiffVisibleRows, | 4 | highlightNativeReviewDiffVisibleRows, |
| ... | @@ -8,10 +8,10 @@ import { | ... | @@ -8,10 +8,10 @@ import { |
| 8 | import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; | 8 | import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; |
| 9 | import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; | 9 | import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; |
| 10 | 10 | ||
| 11 | interface NativeReviewVisibleRange { | 11 | import { |
| 12 | readonly firstRowIndex: number; | 12 | createReviewDiffHighlightScheduler, |
| 13 | readonly lastRowIndex: number; | 13 | type NativeReviewVisibleRange, |
| 14 | } | 14 | } from "./reviewDiffHighlightScheduler"; |
| 15 | 15 | ||
| 16 | function createEmptyTokenPatch(resetKey: string): string { | 16 | function createEmptyTokenPatch(resetKey: string): string { |
| 17 | return JSON.stringify({ resetKey, tokensByRowId: {} }); | 17 | return JSON.stringify({ resetKey, tokensByRowId: {} }); |
| ... | @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { | ... | @@ -43,23 +43,22 @@ export function useNativeReviewDiffHighlighting(input: { |
| 43 | }) { | 43 | }) { |
| 44 | const { enabled, files, resetKey, rows, scheme } = input; | 44 | const { enabled, files, resetKey, rows, scheme } = input; |
| 45 | const highlightedRowIdsRef = useRef<Set<string>>(new Set()); | 45 | const highlightedRowIdsRef = useRef<Set<string>>(new Set()); |
| 46 | const visibleRangeRef = useRef<NativeReviewVisibleRange>({ | 46 | const [visibleRange, setVisibleRange] = useState<NativeReviewVisibleRange>({ |
| 47 | firstRowIndex: 0, | 47 | firstRowIndex: 0, |
| 48 | lastRowIndex: 80, | 48 | lastRowIndex: 80, |
| 49 | }); | 49 | }); |
| 50 | const visibleChunkIndexRef = useRef(0); | 50 | const visibleChunkIndexRef = useRef(0); |
| 51 | const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); | 51 | const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); |
| 52 | const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); | 52 | const [scheduler] = useState(() => createReviewDiffHighlightScheduler(setVisibleRange)); |
| 53 | 53 | ||
| 54 | useEffect(() => { | 54 | useEffect(() => { |
| 55 | scheduler.reset(); | ||
| 55 | highlightedRowIdsRef.current = new Set(); | 56 | highlightedRowIdsRef.current = new Set(); |
| 56 | visibleChunkIndexRef.current = 0; | 57 | visibleChunkIndexRef.current = 0; |
| 57 | visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; | 58 | setVisibleRange({ firstRowIndex: 0, lastRowIndex: 80 }); |
| 58 | setTokensPatchJson(createEmptyTokenPatch(resetKey)); | 59 | setTokensPatchJson(createEmptyTokenPatch(resetKey)); |
| 59 | if (enabled && rows.length > 0) { | 60 | return () => scheduler.cancel(); |
| 60 | setVisibleHighlightRequest((request) => request + 1); | 61 | }, [enabled, resetKey, rows.length, scheduler]); |
| 61 | } | ||
| 62 | }, [enabled, resetKey, rows.length]); | ||
| 63 | 62 | ||
| 64 | useEffect(() => { | 63 | useEffect(() => { |
| 65 | if (!enabled || rows.length === 0) { | 64 | if (!enabled || rows.length === 0) { |
| ... | @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { | ... | @@ -67,7 +66,7 @@ export function useNativeReviewDiffHighlighting(input: { |
| 67 | } | 66 | } |
| 68 | 67 | ||
| 69 | const abortController = new AbortController(); | 68 | const abortController = new AbortController(); |
| 70 | const requestRange = visibleRangeRef.current; | 69 | const requestRange = visibleRange; |
| 71 | const engine: NativeReviewDiffHighlightEngine = "native"; | 70 | const engine: NativeReviewDiffHighlightEngine = "native"; |
| 72 | 71 | ||
| 73 | void (async () => { | 72 | void (async () => { |
| ... | @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { | ... | @@ -119,22 +118,10 @@ export function useNativeReviewDiffHighlighting(input: { |
| 119 | })(); | 118 | })(); |
| 120 | 119 | ||
| 121 | return () => abortController.abort(); | 120 | return () => abortController.abort(); |
| 122 | }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); | 121 | }, [enabled, files, resetKey, rows, scheme, visibleRange]); |
| 123 | |||
| 124 | const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { | ||
| 125 | const previousRange = visibleRangeRef.current; | ||
| 126 | const movedRows = | ||
| 127 | Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + | ||
| 128 | Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); | ||
| 129 | |||
| 130 | visibleRangeRef.current = nextRange; | ||
| 131 | if (movedRows >= 20) { | ||
| 132 | setVisibleHighlightRequest((request) => request + 1); | ||
| 133 | } | ||
| 134 | }, []); | ||
| 135 | 122 | ||
| 136 | return { | 123 | return { |
| 137 | tokensPatchJson, | 124 | tokensPatchJson, |
| 138 | updateVisibleRange, | 125 | updateVisibleRange: scheduler.update, |
| 139 | }; | 126 | }; |
| 140 | } | 127 | } |
apps/mobile/src/features/settings/appearance/useAppearanceCodeSurface.ts+2-2| ... | @@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): { | ... | @@ -21,8 +21,8 @@ export function useAppearanceCodeSurface(): { |
| 21 | ); | 21 | ); |
| 22 | const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); | 22 | const nativeSourceStyle = useMemo(() => createNativeSourceStyle(codeSurface), [codeSurface]); |
| 23 | const nativeReviewDiffStyle = useMemo( | 23 | const nativeReviewDiffStyle = useMemo( |
| 24 | () => createNativeReviewDiffStyle(codeSurface), | 24 | () => createNativeReviewDiffStyle(codeSurface, appearance.codeWordBreak), |
| 25 | [codeSurface], | 25 | [appearance.codeWordBreak, codeSurface], |
| 26 | ); | 26 | ); |
| 27 | 27 | ||
| 28 | return { | 28 | return { |