diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index ee64131ab18..6bc5d8f60f6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -93,6 +93,7 @@ import com.facebook.react.views.text.PreparedLayout; import com.facebook.react.views.text.ReactTextViewManager; import com.facebook.react.views.text.ReactTextViewManagerCallback; +import com.facebook.react.views.text.ReactTypefaceUtils; import com.facebook.react.views.text.TextEffectRegistry; import com.facebook.react.views.text.TextLayoutManager; import java.util.ArrayList; @@ -553,6 +554,7 @@ private NativeArray measureLines( return (NativeArray) TextLayoutManager.measureLines( mReactApplicationContext.getAssets(), + ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), attributedString, paragraphAttributes, PixelUtil.toPixelFromDIP(width), @@ -639,6 +641,7 @@ public long measureText( return TextLayoutManager.measureText( mReactApplicationContext.getAssets(), + ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), attributedString, paragraphAttributes, getYogaSize(minWidth, maxWidth), @@ -666,6 +669,7 @@ public PreparedLayout prepareTextLayout( return TextLayoutManager.createPreparedLayout( mReactApplicationContext.getAssets(), + ReactTypefaceUtils.getFontWeightAdjustment(mReactApplicationContext), attributedString, paragraphAttributes, getYogaSize(minWidth, maxWidth), diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt index bc420d79c67..2d06fcdab42 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextViewManager.kt @@ -158,6 +158,7 @@ public constructor( val spanned: Spannable = TextLayoutManager.getOrCreateSpannableForText( view.context.assets, + ReactTypefaceUtils.getFontWeightAdjustment(view.context), attributedString, reactTextViewManagerCallback, TextEffectRegistry.current, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt index e33daa691f3..fbdcd298611 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTypefaceUtils.kt @@ -7,14 +7,22 @@ package com.facebook.react.views.text +import android.content.Context +import android.content.res.Configuration import android.content.res.AssetManager import android.graphics.Typeface +import android.os.Build import com.facebook.react.bridge.ReadableArray import com.facebook.react.common.ReactConstants import com.facebook.react.common.assets.ReactFontManager +import kotlin.math.max +import kotlin.math.min public object ReactTypefaceUtils { + private const val FONT_WEIGHT_MIN = 1 + private const val FONT_WEIGHT_MAX = 1000 + @JvmStatic public fun parseFontWeight(fontWeightString: String?): Int = when (fontWeightString) { @@ -110,4 +118,33 @@ public object ReactTypefaceUtils { ReactFontManager.getInstance().getTypeface(fontFamilyName, typefaceStyle, assetManager) } } + + @JvmStatic + public fun getFontWeightAdjustment(context: Context): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.resources.configuration.fontWeightAdjustment + } else { + 0 + } + + @JvmStatic + public fun applyFontWeightAdjustment( + typeface: Typeface?, + fontWeightAdjustment: Int, + ): Typeface? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || fontWeightAdjustment == 0) { + return typeface + } + + if (fontWeightAdjustment == Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED) { + return typeface + } + + val baseTypeface = typeface ?: Typeface.DEFAULT + val adjustedWeight = + min(max(baseTypeface.weight + fontWeightAdjustment, FONT_WEIGHT_MIN), FONT_WEIGHT_MAX) + val italic = baseTypeface.style and Typeface.ITALIC != 0 + + return Typeface.create(baseTypeface, adjustedWeight, italic) + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index 4a900ffcf71..3420e04a87b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -109,7 +109,12 @@ internal object TextLayoutManager { private const val DEFAULT_ADJUST_FONT_SIZE_TO_FIT = false - private val tagToSpannableCache = ConcurrentHashMap() + private data class CachedSpannable( + val spannable: Spannable, + val fontWeightAdjustment: Int, + ) + + private val tagToSpannableCache = ConcurrentHashMap() // Lazily cached Method for StaticLayout.Builder.setUseBoundsForWidth (API 35+). // Reflection is needed because some internal targets compile against an SDK older than 35. @@ -123,8 +128,15 @@ internal object TextLayoutManager { } } - fun setCachedSpannableForTag(reactTag: Int, sp: Spannable): Unit { - tagToSpannableCache[reactTag] = sp + fun setCachedSpannableForTag(reactTag: Int, sp: Spannable): Unit = + setCachedSpannableForTag(reactTag, 0, sp) + + fun setCachedSpannableForTag( + reactTag: Int, + fontWeightAdjustment: Int, + sp: Spannable, + ): Unit { + tagToSpannableCache[reactTag] = CachedSpannable(sp, fontWeightAdjustment) } fun deleteCachedSpannableForTag(reactTag: Int): Unit { @@ -238,6 +250,7 @@ internal object TextLayoutManager { @OptIn(UnstableReactNativeAPI::class) private fun buildSpannableFromFragments( assets: AssetManager, + fontWeightAdjustment: Int, fragments: MapBuffer, sb: SpannableStringBuilder, ops: MutableList, @@ -323,6 +336,7 @@ internal object TextLayoutManager { textAttributes.fontFeatureSettings, textAttributes.fontFamily, assets, + fontWeightAdjustment, ), ) ) @@ -426,6 +440,7 @@ internal object TextLayoutManager { @OptIn(UnstableReactNativeAPI::class) private fun buildSpannableFromFragmentsOptimized( assets: AssetManager, + fontWeightAdjustment: Int, fragments: MapBuffer, outputReactTags: IntArray?, textEffectRegistry: TextEffectRegistry?, @@ -552,6 +567,7 @@ internal object TextLayoutManager { fragment.props.fontFeatureSettings, fragment.props.fontFamily, assets, + fontWeightAdjustment, ), start, end, @@ -657,21 +673,66 @@ internal object TextLayoutManager { ): Spannable = getOrCreateSpannableForText(assets, attributedString, reactTextViewManagerCallback, null) + @OptIn(UnstableReactNativeAPI::class) + fun getOrCreateSpannableForText( + assets: AssetManager, + fontWeightAdjustment: Int, + attributedString: MapBuffer, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + ): Spannable = + getOrCreateSpannableForText( + assets, + fontWeightAdjustment, + attributedString, + reactTextViewManagerCallback, + null, + ) + @OptIn(UnstableReactNativeAPI::class) internal fun getOrCreateSpannableForText( assets: AssetManager, attributedString: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry?, + ): Spannable = + getOrCreateSpannableForText( + assets, + 0, + attributedString, + reactTextViewManagerCallback, + textEffectRegistry, + ) + + @OptIn(UnstableReactNativeAPI::class) + internal fun getOrCreateSpannableForText( + assets: AssetManager, + fontWeightAdjustment: Int, + attributedString: MapBuffer, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + textEffectRegistry: TextEffectRegistry?, ): Spannable { var text: Spannable? if (attributedString.contains(AS_KEY_CACHE_ID)) { val cacheId = attributedString.getInt(AS_KEY_CACHE_ID) - text = checkNotNull(tagToSpannableCache[cacheId]) + val cachedSpannable = checkNotNull(tagToSpannableCache[cacheId]) + text = + if (cachedSpannable.fontWeightAdjustment == fontWeightAdjustment) { + cachedSpannable.spannable + } else { + createSpannableFromAttributedString( + assets, + fontWeightAdjustment, + attributedString.getMapBuffer(AS_KEY_FRAGMENTS), + reactTextViewManagerCallback, + null, + textEffectRegistry, + ) + } } else { text = createSpannableFromAttributedString( assets, + fontWeightAdjustment, attributedString.getMapBuffer(AS_KEY_FRAGMENTS), reactTextViewManagerCallback, null, @@ -685,6 +746,7 @@ internal object TextLayoutManager { @OptIn(UnstableReactNativeAPI::class) private fun createSpannableFromAttributedString( assets: AssetManager, + fontWeightAdjustment: Int, fragments: MapBuffer, reactTextViewManagerCallback: ReactTextViewManagerCallback?, outputReactTags: IntArray?, @@ -694,6 +756,7 @@ internal object TextLayoutManager { val spannable = buildSpannableFromFragmentsOptimized( assets, + fontWeightAdjustment, fragments, outputReactTags, textEffectRegistry, @@ -709,7 +772,15 @@ internal object TextLayoutManager { // a new spannable will be wiped out val ops: MutableList = ArrayList() - buildSpannableFromFragments(assets, fragments, sb, ops, outputReactTags, textEffectRegistry) + buildSpannableFromFragments( + assets, + fontWeightAdjustment, + fragments, + sb, + ops, + outputReactTags, + textEffectRegistry, + ) // TODO T31905686: add support for inline Images // While setting the Spans on the final text, we also check whether any of them are images. @@ -829,6 +900,7 @@ internal object TextLayoutManager { paint: TextPaint, baseTextAttributes: TextAttributeProps, assets: AssetManager, + fontWeightAdjustment: Int, ) { if (baseTextAttributes.fontSize != ReactConstants.UNSET) { paint.textSize = baseTextAttributes.fontSize.toFloat() @@ -847,7 +919,7 @@ internal object TextLayoutManager { baseTextAttributes.fontFamily, assets, ) - paint.setTypeface(typeface) + paint.setTypeface(ReactTypefaceUtils.applyFontWeightAdjustment(typeface, fontWeightAdjustment)) if ( baseTextAttributes.fontStyle != ReactConstants.UNSET && @@ -858,6 +930,8 @@ internal object TextLayoutManager { paint.isFakeBoldText = missingStyle and Typeface.BOLD != 0 paint.textSkewX = if ((missingStyle and Typeface.ITALIC) != 0) -0.25f else 0f } + } else { + paint.setTypeface(ReactTypefaceUtils.applyFontWeightAdjustment(null, fontWeightAdjustment)) } } @@ -868,28 +942,31 @@ internal object TextLayoutManager { private fun scratchPaintWithAttributes( baseTextAttributes: TextAttributeProps, assets: AssetManager, + fontWeightAdjustment: Int, ): TextPaint { val paint = checkNotNull(textPaintInstance.get()) paint.setTypeface(null) paint.textSize = 12f paint.isFakeBoldText = false paint.textSkewX = 0f - updateTextPaint(paint, baseTextAttributes, assets) + updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment) return paint } private fun newPaintWithAttributes( baseTextAttributes: TextAttributeProps, assets: AssetManager, + fontWeightAdjustment: Int, ): TextPaint { val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) - updateTextPaint(paint, baseTextAttributes, assets) + updateTextPaint(paint, baseTextAttributes, assets, fontWeightAdjustment) return paint } @OptIn(UnstableReactNativeAPI::class) private fun createLayoutForMeasurement( assets: AssetManager, + fontWeightAdjustment: Int, attributedString: MapBuffer, paragraphAttributes: MapBuffer, width: Float, @@ -902,6 +979,7 @@ internal object TextLayoutManager { val text = getOrCreateSpannableForText( assets, + fontWeightAdjustment, attributedString, reactTextViewManagerCallback, textEffectRegistry, @@ -909,11 +987,19 @@ internal object TextLayoutManager { val paint: TextPaint if (attributedString.contains(AS_KEY_CACHE_ID)) { - paint = text.getSpans(0, 0, ReactTextPaintHolderSpan::class.java)[0].textPaint + val textPaintHolderSpans = text.getSpans(0, 0, ReactTextPaintHolderSpan::class.java) + paint = + if (textPaintHolderSpans.isNotEmpty()) { + textPaintHolderSpans[0].textPaint + } else { + val baseTextAttributes = + TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) + scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment) + } } else { val baseTextAttributes = TextAttributeProps.fromMapBuffer(attributedString.getMapBuffer(AS_KEY_BASE_ATTRIBUTES)) - paint = scratchPaintWithAttributes(baseTextAttributes, assets) + paint = scratchPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment) } return createLayout( @@ -1028,12 +1114,40 @@ internal object TextLayoutManager { heightYogaMeasureMode: YogaMeasureMode, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, + ): PreparedLayout = + createPreparedLayout( + assets, + 0, + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + reactTextViewManagerCallback, + textEffectRegistry, + ) + + @JvmStatic + @OptIn(UnstableReactNativeAPI::class) + fun createPreparedLayout( + assets: AssetManager, + fontWeightAdjustment: Int, + attributedString: ReadableMapBuffer, + paragraphAttributes: ReadableMapBuffer, + width: Float, + widthYogaMeasureMode: YogaMeasureMode, + height: Float, + heightYogaMeasureMode: YogaMeasureMode, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + textEffectRegistry: TextEffectRegistry? = null, ): PreparedLayout { val fragments = attributedString.getMapBuffer(AS_KEY_FRAGMENTS) val reactTags = IntArray(fragments.count) val text = createSpannableFromAttributedString( assets, + fontWeightAdjustment, fragments, reactTextViewManagerCallback, reactTags, @@ -1044,7 +1158,7 @@ internal object TextLayoutManager { val result = createLayout( text, - newPaintWithAttributes(baseTextAttributes, assets), + newPaintWithAttributes(baseTextAttributes, assets, fontWeightAdjustment), attributedString, paragraphAttributes, width, @@ -1195,11 +1309,41 @@ internal object TextLayoutManager { reactTextViewManagerCallback: ReactTextViewManagerCallback?, attachmentsPositions: FloatArray?, textEffectRegistry: TextEffectRegistry? = null, + ): Long = + measureText( + assets, + 0, + attributedString, + paragraphAttributes, + width, + widthYogaMeasureMode, + height, + heightYogaMeasureMode, + reactTextViewManagerCallback, + attachmentsPositions, + textEffectRegistry, + ) + + @JvmStatic + @OptIn(UnstableReactNativeAPI::class) + fun measureText( + assets: AssetManager, + fontWeightAdjustment: Int, + attributedString: MapBuffer, + paragraphAttributes: MapBuffer, + width: Float, + widthYogaMeasureMode: YogaMeasureMode, + height: Float, + heightYogaMeasureMode: YogaMeasureMode, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + attachmentsPositions: FloatArray?, + textEffectRegistry: TextEffectRegistry? = null, ): Long { // TODO(5578671): Handle text direction (see View#getTextDirectionHeuristic) val layout = createLayoutForMeasurement( assets, + fontWeightAdjustment, attributedString, paragraphAttributes, width, @@ -1465,10 +1609,34 @@ internal object TextLayoutManager { height: Float, reactTextViewManagerCallback: ReactTextViewManagerCallback?, textEffectRegistry: TextEffectRegistry? = null, + ): WritableArray = + measureLines( + assetManager, + 0, + attributedString, + paragraphAttributes, + width, + height, + reactTextViewManagerCallback, + textEffectRegistry, + ) + + @JvmStatic + @OptIn(UnstableReactNativeAPI::class) + fun measureLines( + assetManager: AssetManager, + fontWeightAdjustment: Int, + attributedString: MapBuffer, + paragraphAttributes: MapBuffer, + width: Float, + height: Float, + reactTextViewManagerCallback: ReactTextViewManagerCallback?, + textEffectRegistry: TextEffectRegistry? = null, ): WritableArray { val layout = createLayoutForMeasurement( assetManager, + fontWeightAdjustment, attributedString, paragraphAttributes, width, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt index dec85221456..29eb30f1447 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomStyleSpan.kt @@ -33,13 +33,30 @@ internal class CustomStyleSpan( val fontFeatureSettings: String?, val fontFamily: String?, private val assetManager: AssetManager, + private val fontWeightAdjustment: Int = 0, ) : MetricAffectingSpan(), ReactSpan { override fun updateDrawState(ds: TextPaint) { - apply(ds, privateStyle, privateWeight, fontFeatureSettings, fontFamily, assetManager) + apply( + ds, + privateStyle, + privateWeight, + fontFeatureSettings, + fontFamily, + assetManager, + fontWeightAdjustment, + ) } override fun updateMeasureState(paint: TextPaint) { - apply(paint, privateStyle, privateWeight, fontFeatureSettings, fontFamily, assetManager) + apply( + paint, + privateStyle, + privateWeight, + fontFeatureSettings, + fontFamily, + assetManager, + fontWeightAdjustment, + ) } val style: Int @@ -66,12 +83,15 @@ internal class CustomStyleSpan( fontFeatureSettingsParam: String?, family: String?, assetManager: AssetManager, + fontWeightAdjustment: Int, ) { val typeface = ReactTypefaceUtils.applyStyles(paint.typeface, style, weight, family, assetManager) + val adjustedTypeface = + ReactTypefaceUtils.applyFontWeightAdjustment(typeface, fontWeightAdjustment) paint.apply { fontFeatureSettings = fontFeatureSettingsParam - setTypeface(typeface) + setTypeface(adjustedTypeface) isSubpixelText = true isLinearText = true } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt index aae65314509..d0cefb13f88 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt @@ -71,6 +71,7 @@ import com.facebook.react.uimanager.style.LogicalEdge import com.facebook.react.uimanager.style.Overflow import com.facebook.react.views.text.ReactTextUpdate import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles +import com.facebook.react.views.text.ReactTypefaceUtils.getFontWeightAdjustment import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.facebook.react.views.text.TextAttributes @@ -849,7 +850,14 @@ public open class ReactEditText public constructor(context: Context) : AppCompat fontFeatureSettings != null ) { workingText.setSpan( - CustomStyleSpan(fontStyle, fontWeight, fontFeatureSettings, fontFamily, context.assets), + CustomStyleSpan( + fontStyle, + fontWeight, + fontFeatureSettings, + fontFamily, + context.assets, + getFontWeightAdjustment(context), + ), 0, workingText.length, spanFlags, @@ -1109,7 +1117,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat sb.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE, ) - TextLayoutManager.setCachedSpannableForTag(id, sb) + TextLayoutManager.setCachedSpannableForTag(id, getFontWeightAdjustment(context), sb) } public fun setEventDispatcher(eventDispatcher: EventDispatcher?) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt index 9e40dbe5e69..02d20c2af7f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt @@ -69,6 +69,7 @@ import com.facebook.react.views.text.DefaultStyleValuesUtil.getDefaultTextColorH import com.facebook.react.views.text.ReactTextUpdate import com.facebook.react.views.text.ReactTextUpdate.Companion.buildReactTextUpdateFromState import com.facebook.react.views.text.ReactTextViewManagerCallback +import com.facebook.react.views.text.ReactTypefaceUtils.getFontWeightAdjustment import com.facebook.react.views.text.ReactTypefaceUtils.parseFontVariant import com.facebook.react.views.text.TextAttributeProps import com.facebook.react.views.text.TextLayoutManager @@ -1016,6 +1017,7 @@ public open class ReactTextInputManager public constructor() : val spanned = TextLayoutManager.getOrCreateSpannableForText( view.context.assets, + getFontWeightAdjustment(view.context), attributedString, reactTextViewManagerCallback, ) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt new file mode 100644 index 00000000000..c30de8e0afe --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerFontWeightAdjustmentTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.text + +import android.content.res.AssetManager +import android.graphics.Typeface +import android.text.SpannableString +import android.text.TextPaint +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.common.mapbuffer.WritableMapBuffer +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.views.text.internal.span.CustomStyleSpan +import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TextLayoutManagerFontWeightAdjustmentTest { + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + ReactNativeFeatureFlags.dangerouslyReset() + } + + @Test + fun `plain text paint applies Android font weight adjustment`() { + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) + val textAttributes = TextAttributeProps.fromReadableMap(ReactStylesDiffMap(JavaOnlyMap())) + + invokeUpdateTextPaint( + paint, + textAttributes, + RuntimeEnvironment.getApplication().assets, + FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT, + ) + + assertThat(paint.typeface).isNotNull + assertThat(paint.typeface).isNotSameAs(Typeface.DEFAULT) + } + + @Test + fun `plain text paint keeps default typeface unset without font weight adjustment`() { + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) + val textAttributes = TextAttributeProps.fromReadableMap(ReactStylesDiffMap(JavaOnlyMap())) + + invokeUpdateTextPaint( + paint, + textAttributes, + RuntimeEnvironment.getApplication().assets, + 0, + ) + + assertThat(paint.typeface).isNull() + } + + @Test + fun `cached spannable is recreated when Android font weight adjustment changes`() { + val cachedSpannable = SpannableString("A") + cachedSpannable.setSpan( + ReactTextPaintHolderSpan(TextPaint(TextPaint.ANTI_ALIAS_FLAG)), + 0, + cachedSpannable.length, + 0, + ) + TextLayoutManager.setCachedSpannableForTag(CACHE_ID, 0, cachedSpannable) + + val adjustedSpannable = + TextLayoutManager.getOrCreateSpannableForText( + RuntimeEnvironment.getApplication().assets, + FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT, + cachedAttributedString(), + null, + ) + + assertThat(adjustedSpannable).isNotSameAs(cachedSpannable) + val customStyleSpan = + adjustedSpannable.getSpans(0, adjustedSpannable.length, CustomStyleSpan::class.java)[0] + val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG) + + customStyleSpan.updateDrawState(paint) + + assertThat(paint.typeface).isNotSameAs(Typeface.DEFAULT) + } + + private fun invokeUpdateTextPaint( + paint: TextPaint, + textAttributes: TextAttributeProps, + assets: AssetManager, + fontWeightAdjustment: Int, + ) { + val method = + TextLayoutManager::class + .java + .getDeclaredMethod( + "updateTextPaint", + TextPaint::class.java, + TextAttributeProps::class.java, + AssetManager::class.java, + java.lang.Integer.TYPE, + ) + .apply { isAccessible = true } + + method.invoke(TextLayoutManager, paint, textAttributes, assets, fontWeightAdjustment) + } + + private companion object { + const val CACHE_ID = 1001 + const val FONT_WEIGHT_ADJUSTMENT_BOLD_TEXT = 300 + + fun cachedAttributedString(): WritableMapBuffer { + val textAttributes = + WritableMapBuffer().put(TextAttributeProps.TA_KEY_FONT_WEIGHT, "normal").put( + TextAttributeProps.TA_KEY_FONT_SIZE, + 14.0, + ) + val fragment = + WritableMapBuffer() + .put(TextLayoutManager.FR_KEY_STRING, "A") + .put(TextLayoutManager.FR_KEY_REACT_TAG, 1) + .put(TextLayoutManager.FR_KEY_TEXT_ATTRIBUTES, textAttributes) + val fragments = WritableMapBuffer().put(0, fragment) + return WritableMapBuffer() + .put(TextLayoutManager.AS_KEY_CACHE_ID, CACHE_ID) + .put(TextLayoutManager.AS_KEY_FRAGMENTS, fragments) + .put(TextLayoutManager.AS_KEY_BASE_ATTRIBUTES, WritableMapBuffer()) + } + } +}