|
Function("renderContextChip") { payloadJson: String -> |
|
val resources = appContext.reactContext?.resources ?: return@Function null |
|
val metrics = resources.displayMetrics |
|
val fontScale = resources.configuration.fontScale |
|
val key = "${metrics.density}:$fontScale:${metrics.widthPixels}:$payloadJson" |
|
chipImages.get(key)?.let { return@Function it } |
|
val payload = JSONObject(payloadJson) |
|
val chip = T3ContextChip( |
|
content = T3ContextChip.Content( |
|
label = payload.optString("label").take(4096), |
|
symbol = payload.optString("symbol", "doc") |
|
), |
|
// The line box around the chip is measured with `fontScale` below, so the chip has to |
|
// carry it too, or it shrinks against the words beside it at a larger text size. |
|
fontSize = |
|
payload.optDouble("fontSize", 12.0).toFloat().coerceIn(10f, 40f) * |
|
metrics.density * fontScale, |
|
colors = T3ContextChip.Colors( |
|
accent = T3ContextChip.color(payload.optString("accent"), Color.GRAY), |
|
foreground = T3ContextChip.color(payload.optString("foreground"), Color.BLACK), |
|
border = T3ContextChip.color(payload.optString("border"), Color.GRAY) |
|
), |
|
maximumWidth = (metrics.widthPixels - 80 * metrics.density).coerceAtLeast(100f), |
|
density = metrics.density, |
|
) |
|
// toInt() truncates, so a fractional pixel of the chip would fall outside the bitmap |
|
// and take the right-hand border with it. Round up: a spare column costs nothing. |
|
// One spare pixel of transparency on each side. Whatever rounding happens between the |
|
// bitmap's pixels and the box's dp then falls on padding instead of on the border. |
|
val bleed = 1 |
|
val bitmap = Bitmap.createBitmap( |
|
ceil(chip.width).toInt() + bleed * 2, |
|
ceil(chip.height).toInt() + bleed * 2, |
|
Bitmap.Config.ARGB_8888 |
|
) |
|
chip.draw(Canvas(bitmap), bleed.toFloat(), bleed.toFloat()) |
|
val bytes = ByteArrayOutputStream() |
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, bytes) |
|
bitmap.recycle() |
|
// React Native sits an inline view's box on the text baseline and grows the line's |
|
// ascent to fit it, so a box as tall as the chip lifts the chip above the words and |
|
// pushes the baseline down. Report a box no taller than the paragraph font's ascent, |
|
// which leaves the line exactly as tall as a line of plain text, plus where inside |
|
// that box the bitmap must sit so the chip centres on the font's ascent/descent box: |
|
// the same rule the composer's ReplacementSpan uses to draw its chips. |
|
val lineMetrics = paragraphFontMetrics( |
|
payload.optJSONObject("text"), |
|
fontScale * metrics.density, |
|
) |
|
val ascent = -lineMetrics.ascent |
|
val descent = lineMetrics.descent |
|
val bitmapWidth = ceil(chip.width) + bleed * 2 |
|
val bitmapHeight = ceil(chip.height) + bleed * 2 |
|
val result = mapOf<String, Any>( |
|
"uri" to |
|
"data:image/png;base64,${Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP)}", |
|
// Layout rounds dp back to whole pixels. Reporting a hair less than the bitmap lets |
|
// that rounding land inside the image and crop its right-hand border, so round the |
|
// box up: an extra fraction of a pixel is invisible, a missing border is not. |
|
"width" to bitmapWidth / metrics.density, |
|
"height" to bitmapHeight / metrics.density, |
|
"boxHeight" to ascent / metrics.density, |
|
"offsetY" to (ascent + descent - bitmapHeight) / 2f / metrics.density, |
|
) |
|
chipImages.put(key, result) |
|
result |
Before submitting
Area
apps/mobile
Steps to reproduce
Expected behavior
Each chip sits in the gap the text leaves for it, centred on its line.
Actual behavior
The text leaves a correctly sized gap for each chip, but the chip is drawn half a line or more below that gap and covers words on the next line. In the screenshot,
bincovers "edit your",cargocovers "needs", andAGENTS.md/CLAUDE.mdcover the line below them.The first chip in the block, on the same line as the GitHub-icon link, is aligned. Later chips are offset by roughly 23 → 30 → 32 px (measured on the screenshot scaled to 900 px wide) and then stay at about that offset.
Two chips with the same label (
bin) render differently: one aligned, one offset.renderContextChipreturns the same cached bitmap,boxHeightandoffsetYfor identical payloads, so the difference must be in where the inline<View>is placed, not in the chip image.Possible cause, from reading the source and not verified on a device: the inline-view positions from React Native's text measurement don't match the line layout the
TextViewactually draws, so chips drift away from their text. The comment aboveMarkdownSpannableFactorydescribes a similar measurement/render mismatch shifting inline images. Here the drift appears after lines that contain an inline link-icon<Image>or inline code, so those lines may measure taller than they render.Relevant code:
t3code/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx
Lines 396 to 423 in c2fa9fc
renderContextChip(boxHeight/offsetY):t3code/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
Lines 215 to 280 in c2fa9fc
MarkdownSpannableFactorynote:t3code/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt
Lines 37 to 44 in c2fa9fc
Impact
Minor bug or occasional failure
Version or commit
Android app 1.3.0 (
com.t3tools.t3code)Environment
Android phone, 1080×2400 display
Logs or stack traces
No response
Screenshots, recordings, or supporting files
Workaround
No response