Skip to content

Commit

Permalink
Refactor TextSemanticsNodeMapper to commonize the text wireframe logic
Browse files Browse the repository at this point in the history
  • Loading branch information
ambushwork committed Nov 19, 2024
1 parent 352d252 commit aed9992
Show file tree
Hide file tree
Showing 14 changed files with 395 additions and 210 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ package com.datadog.android.sessionreplay.compose.internal.mappers.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.text.font.GenericFontFamily
import androidx.compose.ui.text.style.TextAlign
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.utils.BackgroundInfo
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
Expand All @@ -21,6 +24,11 @@ internal abstract class AbstractSemanticsNodeMapper(
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : SemanticsNodeMapper {

protected val defaultTextStyle = MobileSegment.TextStyle(
size = DEFAULT_FONT_SIZE,
color = DEFAULT_TEXT_COLOR,
family = DEFAULT_FONT_FAMILY
)
protected fun resolveId(semanticsNode: SemanticsNode, currentIndex: Int = 0): Long {
// Use semantics node intrinsic id as the higher endian of Long type and the index of
// the wireframe inside the node as the lower endian to generate a unique id.
Expand Down Expand Up @@ -63,6 +71,41 @@ internal abstract class AbstractSemanticsNodeMapper(
)
}

protected fun resolveTextLayoutInfoToTextStyle(
parentContext: UiContext,
textLayoutInfo: TextLayoutInfo
): MobileSegment.TextStyle {
return MobileSegment.TextStyle(
family = when (val value = textLayoutInfo.fontFamily) {
is GenericFontFamily -> value.name
else -> DEFAULT_FONT_FAMILY
},
size = textLayoutInfo.fontSize,
color = convertColor(textLayoutInfo.color.toLong()) ?: parentContext.parentContentColor
?: DEFAULT_TEXT_COLOR
)
}

protected fun resolveTextAlign(textLayoutInfo: TextLayoutInfo): MobileSegment.TextPosition {
val align = when (textLayoutInfo.textAlign) {
TextAlign.Start,
TextAlign.Left -> MobileSegment.Horizontal.LEFT

TextAlign.End,
TextAlign.Right -> MobileSegment.Horizontal.RIGHT

TextAlign.Justify,
TextAlign.Center -> MobileSegment.Horizontal.CENTER

else -> MobileSegment.Horizontal.LEFT
}
return MobileSegment.TextPosition(
alignment = MobileSegment.Alignment(
horizontal = align
)
)
}

protected fun convertColor(color: Long): String? {
return if (color == UNSPECIFIED_COLOR) {
null
Expand All @@ -81,5 +124,8 @@ internal abstract class AbstractSemanticsNodeMapper(
private const val COMPOSE_COLOR_SHIFT = 32
private const val MAX_ALPHA = 255
private const val SEMANTICS_ID_BIT_SHIFT = 32
private const val DEFAULT_FONT_SIZE = 12L
private const val DEFAULT_FONT_FAMILY = "Roboto, sans-serif"
protected const val DEFAULT_TEXT_COLOR = "#000000FF"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign

internal data class TextLayoutInfo(
val text: String,
val color: ULong,
val fontSize: Long,
val fontFamily: FontFamily?,
val textAlign: TextAlign? = TextAlign.Start
)
Original file line number Diff line number Diff line change
Expand Up @@ -6,152 +6,58 @@

package com.datadog.android.sessionreplay.compose.internal.mappers.semantics

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.GenericFontFamily
import androidx.compose.ui.text.style.TextAlign
import com.datadog.android.sessionreplay.compose.internal.data.SemanticsWireframe
import com.datadog.android.sessionreplay.compose.internal.data.UiContext
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection
import com.datadog.android.sessionreplay.compose.internal.reflection.getSafe
import com.datadog.android.sessionreplay.compose.internal.utils.SemanticsUtils
import com.datadog.android.sessionreplay.model.MobileSegment
import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback
import com.datadog.android.sessionreplay.utils.ColorStringFormatter

internal class TextSemanticsNodeMapper(
internal open class TextSemanticsNodeMapper(
colorStringFormatter: ColorStringFormatter,
semanticsUtils: SemanticsUtils = SemanticsUtils()
private val semanticsUtils: SemanticsUtils = SemanticsUtils()
) : AbstractSemanticsNodeMapper(colorStringFormatter, semanticsUtils) {

override fun map(
semanticsNode: SemanticsNode,
parentContext: UiContext,
asyncJobStatusCallback: AsyncJobStatusCallback
): SemanticsWireframe {
val text = resolveText(semanticsNode.config)
val textStyle = resolveTextStyle(semanticsNode, parentContext) ?: defaultTextStyle
val textWireframe = resolveTextWireFrame(parentContext, semanticsNode)
return SemanticsWireframe(
wireframes = listOfNotNull(textWireframe),
parentContext
)
}

protected fun resolveTextWireFrame(
parentContext: UiContext,
semanticsNode: SemanticsNode
): MobileSegment.Wireframe.TextWireframe? {
val textLayoutInfo = semanticsUtils.resolveTextLayoutInfo(semanticsNode)
val capturedText = textLayoutInfo?.text
val bounds = resolveBounds(semanticsNode)
val textWireframe = text?.let {
return capturedText?.let { text ->
MobileSegment.Wireframe.TextWireframe(
id = semanticsNode.id.toLong(),
x = bounds.x,
y = bounds.y,
width = bounds.width,
height = bounds.height,
text = text,
textStyle = textStyle,
textPosition = resolveTextAlign(semanticsNode)
)
}
return SemanticsWireframe(
wireframes = listOfNotNull(textWireframe),
parentContext
)
}

private fun resolveTextAlign(semanticsNode: SemanticsNode): MobileSegment.TextPosition? {
return resolveSemanticsTextStyle(semanticsNode)?.let {
val align = when (it.textAlign) {
TextAlign.Start,
TextAlign.Left -> MobileSegment.Horizontal.LEFT

TextAlign.End,
TextAlign.Right -> MobileSegment.Horizontal.RIGHT

TextAlign.Justify,
TextAlign.Center -> MobileSegment.Horizontal.CENTER

else -> MobileSegment.Horizontal.LEFT
}
MobileSegment.TextPosition(
alignment = MobileSegment.Alignment(
horizontal = align
)
)
}
}

private fun resolveTextStyle(semanticsNode: SemanticsNode, parentContext: UiContext): MobileSegment.TextStyle? {
return resolveSemanticsTextStyle(semanticsNode)?.let { textStyle ->
val color = resolveModifierColor(semanticsNode) ?: textStyle.color
MobileSegment.TextStyle(
family = when (val value = textStyle.fontFamily) {
is GenericFontFamily -> value.name
else -> DEFAULT_FONT_FAMILY
},
size = textStyle.fontSize.value.toLong(),
color = convertColor(color.value.toLong()) ?: parentContext.parentContentColor
?: DEFAULT_TEXT_COLOR
textStyle = resolveTextStyle(parentContext, textLayoutInfo),
textPosition = resolveTextAlign(textLayoutInfo)
)
}
}

private fun resolveModifierColor(semanticsNode: SemanticsNode): Color? {
val modifier = semanticsNode.layoutInfo.getModifierInfo().firstOrNull {
ComposeReflection.TextStringSimpleElement?.isInstance(it.modifier) ?: false
}?.modifier
return if (modifier != null && ComposeReflection.TextStringSimpleElement?.isInstance(modifier) == true) {
val colorProducer = ComposeReflection.ColorProducerField?.getSafe(modifier) as? ColorProducer
return colorProducer?.invoke()
} else {
null
}
}

private fun resolveSemanticsTextStyle(semanticsNode: SemanticsNode): TextStyle? {
val textLayoutResults = mutableListOf<TextLayoutResult>()
semanticsNode.config.getOrNull(SemanticsActions.GetTextLayoutResult)?.action?.invoke(textLayoutResults)
return textLayoutResults.firstOrNull()?.layoutInput?.style
}

private fun resolveText(semanticsConfiguration: SemanticsConfiguration): String? {
return semanticsConfiguration.firstOrNull {
it.key.name == KEY_CONFIG_TEXT
}?.let {
return resolveAnnotatedString(it.value)
}
}

private fun resolveAnnotatedString(value: Any?): String {
return if (value is AnnotatedString) {
if (value.paragraphStyles.isEmpty() &&
value.spanStyles.isEmpty() &&
value.getStringAnnotations(0, value.text.length).isEmpty()
) {
value.text
} else {
// Save space if we there is text only in the object
value.toString()
}
} else if (value is Collection<*>) {
val sb = StringBuilder()
value.forEach {
resolveAnnotatedString(it).let {
sb.append(it)
}
}
sb.toString()
} else {
value.toString()
}
}

companion object {
private const val KEY_CONFIG_TEXT = "Text"
private const val DEFAULT_FONT_FAMILY = "Roboto, sans-serif"
private const val DEFAULT_TEXT_COLOR = "#000000FF"
private const val DEFAULT_FONT_SIZE = 12L
private val defaultTextStyle = MobileSegment.TextStyle(
size = DEFAULT_FONT_SIZE,
color = DEFAULT_TEXT_COLOR,
family = DEFAULT_FONT_FAMILY
)
protected fun resolveTextStyle(
parentContext: UiContext,
textLayoutInfo: TextLayoutInfo?
): MobileSegment.TextStyle {
return textLayoutInfo?.let {
resolveTextLayoutInfoToTextStyle(parentContext, it)
} ?: defaultTextStyle
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.TextLayoutInput
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Density
import com.datadog.android.sessionreplay.compose.internal.mappers.semantics.TextLayoutInfo
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.CompositionField
import com.datadog.android.sessionreplay.compose.internal.reflection.ComposeReflection.GetInnerLayerCoordinatorMethod
Expand Down Expand Up @@ -82,7 +87,8 @@ internal class SemanticsUtils {
currentBounds = shrinkInnerBounds(modifierInfo.modifier, currentBounds)
currentBackgroundInfo = currentBackgroundInfo.copy(globalBounds = currentBounds)
} else if (ComposeReflection.GraphicsLayerElementClass?.isInstance(modifierInfo.modifier) == true) {
val cornerRadius = resolveCornerRadius(modifierInfo.modifier, currentBounds, density)
val cornerRadius =
resolveClipShape(modifierInfo.modifier, currentBounds, density) ?: 0f
currentBackgroundInfo = currentBackgroundInfo.copy(cornerRadius = cornerRadius)
}
}
Expand All @@ -99,10 +105,6 @@ internal class SemanticsUtils {
}
}

internal fun resolveBackgroundInfoId(backgroundInfo: BackgroundInfo): Int {
return System.identityHashCode(backgroundInfo)
}

private fun shrinkInnerBounds(
modifier: Modifier,
currentBounds: GlobalBounds
Expand Down Expand Up @@ -149,21 +151,54 @@ internal class SemanticsUtils {
}
}

private fun resolveCornerRadius(modifier: Modifier, currentBounds: GlobalBounds, density: Density): Float {
private fun resolveClipShape(
modifier: Modifier,
currentBounds: GlobalBounds,
density: Density
): Float? {
val shape = ComposeReflection.ClipShapeField?.getSafe(modifier) as? Shape
return shape?.let {
val size = Size(
currentBounds.width.toFloat() * density.density,
currentBounds.height.toFloat() * density.density
)
// We only have a single value for corner radius, so we default to using the
// top left (i.e.: topStart) corner's value and apply it to all corners
// it.topStart.toPx(size, density) / density.density
if (it is RoundedCornerShape) {
it.topStart.toPx(size, density) / density.density
} else {
0f
}
} ?: 0f
resolveCornerRadius(it, currentBounds, density)
}
}

internal fun resolveCornerRadius(
shape: Shape,
currentBounds: GlobalBounds,
density: Density
): Float {
val size = Size(
currentBounds.width.toFloat() * density.density,
currentBounds.height.toFloat() * density.density
)
// We only have a single value for corner radius, so we default to using the
// top left (i.e.: topStart) corner's value and apply it to all corners
// it.topStart.toPx(size, density) / density.density
return if (shape is RoundedCornerShape) {
shape.topStart.toPx(size, density) / density.density
} else {
0f
}
}

internal fun resolveTextLayoutInfo(semanticsNode: SemanticsNode): TextLayoutInfo? {
val textLayoutResults = mutableListOf<TextLayoutResult>()
semanticsNode.config.getOrNull(SemanticsActions.GetTextLayoutResult)?.action?.invoke(
textLayoutResults
)
val layoutInput = textLayoutResults.firstOrNull()?.layoutInput
return layoutInput?.let {
convertTextLayoutInfo(it)
}
}

private fun convertTextLayoutInfo(layoutInput: TextLayoutInput): TextLayoutInfo {
return TextLayoutInfo(
text = resolveAnnotatedString(layoutInput.text),
color = layoutInput.style.color.value,
textAlign = layoutInput.style.textAlign,
fontSize = layoutInput.style.fontSize.value.toLong(),
fontFamily = layoutInput.style.fontFamily
)
}
}
Loading

0 comments on commit aed9992

Please sign in to comment.