diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt index 7b1c175e2115f..c0084fa38c95c 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/LegacyPlatformTextInputServiceAdapter.skiko.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DEPRECATION") + package androidx.compose.foundation.text.input.internal import androidx.compose.runtime.getValue @@ -23,6 +25,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.platform.TextEditorOffsetMapping import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.CommitTextCommand @@ -48,6 +51,7 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): private var textFieldValue by mutableStateOf(TextFieldValue()) private var textLayoutResult by mutableStateOf(null) + private var offsetMapping by mutableStateOf(OffsetMapping.Identity) private var focusedRectInRoot by mutableStateOf(Rect.Zero) private var textFieldRectInRoot by mutableStateOf(Rect.Zero) private var textClippingRectInRoot by mutableStateOf(Rect.Zero) @@ -94,6 +98,7 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): ) { this.textFieldValue = textFieldValue this.textLayoutResult = textLayoutResult + this.offsetMapping = offsetMapping val matrix = Matrix().also { textFieldToRootTransform(it) } textFieldRectInRoot = matrix.map(decorationBoxBounds) @@ -105,6 +110,26 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): override fun startStylusHandwriting() {} + private val transformedOffsetMapping = object : TextEditorOffsetMapping { + override fun mapToTransformed(range: TextRange): TextRange = if (range.collapsed) { + offsetMapping.originalToTransformed(range.start).let(::TextRange) + } else { + TextRange( + start = offsetMapping.originalToTransformed(range.start), + end = offsetMapping.originalToTransformed(range.end) + ) + } + + override fun mapFromTransformed(range: TextRange): TextRange = if (range.collapsed) { + offsetMapping.transformedToOriginal(range.start).let(::TextRange) + } else { + TextRange( + start = offsetMapping.transformedToOriginal(range.start), + end = offsetMapping.transformedToOriginal(range.end) + ) + } + } + private fun makeRequest( imeOptions: ImeOptions, onEditCommand: (List) -> Unit, @@ -136,6 +161,7 @@ internal actual fun createLegacyPlatformTextInputServiceAdapter(): onEditCommand = onEditCommand, onImeAction = onImeActionPerformed, textLayoutResult = { textLayoutResult }, + offsetMapping = transformedOffsetMapping, focusedRectInRoot = { focusedRectInRoot }, textFieldRectInRoot = { textFieldRectInRoot }, textClippingRectInRoot = { textClippingRectInRoot }, diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt index 3f68cd97390c1..c31a61c77cbee 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/TextInputSession.skiko.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession +import androidx.compose.ui.platform.TextEditorOffsetMapping import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -111,6 +112,7 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe onEditCommand = ::onEditCommand, onImeAction = onImeAction, textLayoutResult = layoutState::layoutResult, + offsetMapping = VisualTextOffsetMapping(state), focusedRectInRoot = ::focusedRectInRoot, textFieldRectInRoot = ::textFieldRectInRoot, textClippingRectInRoot = ::textClippingRectInRoot, @@ -125,6 +127,23 @@ internal actual suspend fun PlatformTextInputSession.platformSpecificTextInputSe private fun TextFieldCharSequence.toTextFieldValue() = TextFieldValue(toString(), selection, composition) +@OptIn(ExperimentalComposeUiApi::class) +private class VisualTextOffsetMapping( + private val state: TransformedTextFieldState, +) : TextEditorOffsetMapping { + override fun mapToTransformed(range: TextRange): TextRange = + TextRange( + start = state.mapToTransformed(range.start).start, + end = state.mapToTransformed(range.end).start + ) + + override fun mapFromTransformed(range: TextRange): TextRange = + TextRange( + start = state.mapFromTransformed(range.start).start, + end = state.mapFromTransformed(range.end).start + ) +} + @Suppress("NOTHING_TO_INLINE") @OptIn(ExperimentalComposeUiApi::class) private inline fun (() -> TextFieldCharSequence).asTextEditorState() = object : TextEditorState { @@ -245,6 +264,7 @@ internal data class SkikoPlatformTextInputMethodRequest( override val onEditCommand: (List) -> Unit, override val onImeAction: ((ImeAction) -> Unit)?, override val textLayoutResult: () -> TextLayoutResult?, + override val offsetMapping: TextEditorOffsetMapping, override val focusedRectInRoot: () -> Rect?, override val textFieldRectInRoot: () -> Rect?, override val textClippingRectInRoot: () -> Rect?, diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/TextInputServiceAdapter.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/TextInputServiceAdapter.ios.kt index eeb934d54ba48..632ac9c4967fa 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/TextInputServiceAdapter.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/TextInputServiceAdapter.ios.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DEPRECATION") + package androidx.compose.ui.scene import androidx.compose.runtime.getValue @@ -23,6 +25,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.platform.PlatformTextInputMethodRequest +import androidx.compose.ui.platform.TextEditorOffsetMapping import androidx.compose.ui.platform.TextInputService import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -47,7 +50,6 @@ import kotlinx.coroutines.launch * Mimics the functionality of the LegacyPlatformTextInputServiceAdapter. Used to provide backward * compatibility for the [androidx.compose.ui.text.input.PlatformTextInputService]. */ -@Suppress("DEPRECATION") internal class TextInputServiceAdapter( private val textInputService: TextInputService, private val coroutineScope: CoroutineScope, @@ -61,6 +63,7 @@ internal class TextInputServiceAdapter( private var textFieldValue by mutableStateOf(TextFieldValue()) private var textLayoutResult by mutableStateOf(null) + private var offsetMapping by mutableStateOf(OffsetMapping.Identity) private var focusedRectInRoot by mutableStateOf(Rect.Zero) private var textFieldRectInRoot by mutableStateOf(Rect.Zero) private var textClippingRectInRoot by mutableStateOf(Rect.Zero) @@ -104,6 +107,7 @@ internal class TextInputServiceAdapter( ) { this.textFieldValue = textFieldValue this.textLayoutResult = textLayoutResult + this.offsetMapping = offsetMapping val matrix = Matrix().also { textFieldToRootTransform(it) } textFieldRectInRoot = matrix.map(decorationBoxBounds) @@ -113,6 +117,26 @@ internal class TextInputServiceAdapter( unclippedTextOffsetInRoot = textClippingRectInRoot.topLeft - innerTextFieldBounds.topLeft } + private val transformedOffsetMapping = object : TextEditorOffsetMapping { + override fun mapToTransformed(range: TextRange): TextRange = if (range.collapsed) { + TextRange(offsetMapping.originalToTransformed(range.start)) + } else { + TextRange( + start = offsetMapping.originalToTransformed(range.start), + end = offsetMapping.originalToTransformed(range.end) + ) + } + + override fun mapFromTransformed(range: TextRange): TextRange = if (range.collapsed) { + TextRange(offsetMapping.transformedToOriginal(range.start)) + } else { + TextRange( + start = offsetMapping.transformedToOriginal(range.start), + end = offsetMapping.transformedToOriginal(range.end) + ) + } + } + private fun makeRequest( imeOptions: ImeOptions, onEditCommand: (List) -> Unit, @@ -136,6 +160,8 @@ internal class TextInputServiceAdapter( override val onImeAction: (ImeAction) -> Unit get() = onImeActionPerformed override val textLayoutResult: () -> TextLayoutResult? get() = { this@TextInputServiceAdapter.textLayoutResult } + override val offsetMapping: TextEditorOffsetMapping + get() = transformedOffsetMapping override val focusedRectInRoot: () -> Rect? get() = { this@TextInputServiceAdapter.focusedRectInRoot } override val textFieldRectInRoot: () -> Rect? diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt index 0f7fd714feeb0..41785bac71728 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/ComposeTextInputConnection.ios.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.TextToolbarStatus import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.detachedCopy +import androidx.compose.ui.platform.mapToTransformed import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.style.ResolvedTextDirection @@ -133,15 +134,16 @@ internal open class ComposeTextInputConnection( override fun caretDpRectForPosition(position: Int): DpRect? { val offset = textOffsetInView ?: return null val text = currentTextFieldValue?.text ?: return null - val currentTextLayoutResult = textLayoutResult ?: return null + val currentTextLayoutResult = transformedTextLayoutResult ?: return null if (position < 0 || position > text.length) { return null } - if (position > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { + val transformedPosition = offsetMapping.mapToTransformed(position) + if (transformedPosition > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { return null } - val rect = currentTextLayoutResult.getCursorRect(position).translate(offset) + val rect = currentTextLayoutResult.getCursorRect(transformedPosition).translate(offset) return rect.toDpRect(rootView.density).let { val halfWidth = CURSOR_THICKNESS / 2 val center = (it.left + it.right) / 2 @@ -174,23 +176,27 @@ internal open class ComposeTextInputConnection( if (range.collapsed || isIncorrect(range)) { return emptySequence() } + val transformedRange = offsetMapping.mapToTransformed(range) + if (transformedRange.collapsed) { + return emptySequence() + } val offset = textOffsetInView ?: return emptySequence() - val currentTextLayoutResult = textLayoutResult ?: return emptySequence() - if (range.end > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { + val transformedTextLayoutResult = transformedTextLayoutResult ?: return emptySequence() + if (transformedRange.end > transformedTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { return emptySequence() } - val firstLine = currentTextLayoutResult.getLineForOffset(range.start) - val lastLine = currentTextLayoutResult.getLineForOffset(range.end) - val startX = currentTextLayoutResult.getCursorRect(range.start).left - val endX = currentTextLayoutResult.getCursorRect(range.end).left + val firstLine = transformedTextLayoutResult.getLineForOffset(transformedRange.start) + val lastLine = transformedTextLayoutResult.getLineForOffset(transformedRange.end) + val startX = transformedTextLayoutResult.getCursorRect(transformedRange.start).left + val endX = transformedTextLayoutResult.getCursorRect(transformedRange.end).left val density = rootView.density return (firstLine..lastLine).asSequence().mapNotNull { line -> - val lineLeft = currentTextLayoutResult.getLineLeft(line) - val lineRight = currentTextLayoutResult.getLineRight(line) - val isRtl = currentTextLayoutResult.getParagraphDirection( - currentTextLayoutResult.getLineStart(line) + val lineLeft = transformedTextLayoutResult.getLineLeft(line) + val lineRight = transformedTextLayoutResult.getLineRight(line) + val isRtl = transformedTextLayoutResult.getParagraphDirection( + transformedTextLayoutResult.getLineStart(line) ) == ResolvedTextDirection.Rtl val lineStartX = if (isRtl) lineRight else lineLeft @@ -203,9 +209,9 @@ internal open class ComposeTextInputConnection( } Rect( left = min(from, to), - top = currentTextLayoutResult.getLineTop(line), + top = transformedTextLayoutResult.getLineTop(line), right = max(from, to), - bottom = currentTextLayoutResult.getLineBottom(line) + bottom = transformedTextLayoutResult.getLineBottom(line) ).translate(offset).toDpRect(density) } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt index b6a933f12be0d..5e4f40ea461ee 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/NativeTextInputConnection.ios.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.platform.toUIColor import androidx.compose.ui.platform.detachedCopy +import androidx.compose.ui.platform.mapFromTransformed +import androidx.compose.ui.platform.mapToTransformed import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextRange @@ -131,9 +133,9 @@ internal class NativeTextInputConnection( // field with the IntermediateTextScrollView (which contains IntermediateUITextView) // to ensure native iOS text input controls // align correctly with the rendered text. - val layoutResult = textLayoutResult ?: return + val transformedTextLayoutResult = transformedTextLayoutResult ?: return - val contentBounds = calculateContentBounds(layoutResult, rect, offset) + val contentBounds = calculateContentBounds(transformedTextLayoutResult, rect, offset) currentContentBounds = contentBounds val contentInsets = calculateContentInsets(rect, contentBounds) currentContentInsets = contentInsets @@ -174,11 +176,12 @@ internal class NativeTextInputConnection( if (position < 0 || position > text.length) { return null } - val currentTextLayoutResult = textLayoutResult ?: return null - if (position > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { + val transformedTextLayoutResult = transformedTextLayoutResult ?: return null + val transformedPosition = offsetMapping.mapToTransformed(position) + if (transformedPosition > transformedTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { return null } - val rect = currentTextLayoutResult.getCursorRect(position) + val rect = transformedTextLayoutResult.getCursorRect(transformedPosition) return rect.toDpRect(rootView.density).let { val halfWidth = CURSOR_THICKNESS / 2 val center = (it.left + it.right) / 2 @@ -192,17 +195,23 @@ internal class NativeTextInputConnection( if (range.collapsed || isIncorrect(range)) { return emptyList() } - val currentTextLayoutResult = textLayoutResult ?: return emptyList() + val transformedRange = offsetMapping.mapToTransformed(range) + if (transformedRange.collapsed) { + return emptyList() + } + val transformedTextLayoutResult = transformedTextLayoutResult ?: return emptyList() // Layout in native text input mode may be outdated, so not checking this may cause OOB error // This workaround should be deleted after https://youtrack.jetbrains.com/issue/CMP-9767/ - if (range.end > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) return emptyList() + if (transformedRange.end > transformedTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { + return emptyList() + } - val startSelectionHandleRect = currentTextLayoutResult.getCursorRect(range.start) - val endSelectionHandleRect = currentTextLayoutResult.getCursorRect(range.end) + val startSelectionHandleRect = transformedTextLayoutResult.getCursorRect(transformedRange.start) + val endSelectionHandleRect = transformedTextLayoutResult.getCursorRect(transformedRange.end) - val firstLineNumber = currentTextLayoutResult.getLineForOffset(range.start) - val lastLineNumber = currentTextLayoutResult.getLineForOffset(range.end) + val firstLineNumber = transformedTextLayoutResult.getLineForOffset(transformedRange.start) + val lastLineNumber = transformedTextLayoutResult.getLineForOffset(transformedRange.end) return if (firstLineNumber == lastLineNumber) { listOf( @@ -258,8 +267,8 @@ internal class NativeTextInputConnection( isVertical = false ) - val lastLineStartRect = currentTextLayoutResult.getCursorRect( - currentTextLayoutResult.getLineStart(lastLineNumber) + val lastLineStartRect = transformedTextLayoutResult.getCursorRect( + transformedTextLayoutResult.getLineStart(lastLineNumber) ) val lastLineRect = TextInputSelectionRect( dpRect = Rect( @@ -284,25 +293,30 @@ internal class NativeTextInputConnection( if (range.collapsed || isIncorrect(range)) { return null } - val currentTextLayoutResult = textLayoutResult ?: return null + val transformedRange = offsetMapping.mapToTransformed(range) + if (transformedRange.collapsed) { + return null + } + val transformedTextLayoutResult = transformedTextLayoutResult ?: return null // Layout in native text input mode may be outdated, so not checking this may cause OOB error // This workaround should be deleted after https://youtrack.jetbrains.com/issue/CMP-9767/ - if (range.end > currentTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) return null - - val startHandleLineNumber = currentTextLayoutResult.getLineForOffset(range.start) - val endHandleLineNumber = currentTextLayoutResult.getLineForOffset(range.end) + if (transformedRange.end > transformedTextLayoutResult.multiParagraph.intrinsics.annotatedString.length) { + return null + } - val startHandleRect = currentTextLayoutResult.getCursorRect(range.start) + val startHandleLineNumber = transformedTextLayoutResult.getLineForOffset(transformedRange.start) + val endHandleLineNumber = transformedTextLayoutResult.getLineForOffset(transformedRange.end) + val startHandleRect = transformedTextLayoutResult.getCursorRect(transformedRange.start) return if (startHandleLineNumber == endHandleLineNumber) { Rect( topLeft = startHandleRect.topLeft, - bottomRight = currentTextLayoutResult.getCursorRect(range.end).bottomRight + bottomRight = transformedTextLayoutResult.getCursorRect(transformedRange.end).bottomRight ).toDpRect(rootView.density) } else { - val startLineNumber = currentTextLayoutResult.getLineForOffset(range.start) - val startLineRight = currentTextLayoutResult.getLineRight(startLineNumber) + val startLineNumber = transformedTextLayoutResult.getLineForOffset(transformedRange.start) + val startLineRight = transformedTextLayoutResult.getLineRight(startLineNumber) Rect( startHandleRect.left, startHandleRect.top, @@ -313,21 +327,24 @@ internal class NativeTextInputConnection( } override fun closestPositionToPoint(point: DpOffset): Int? { - return textLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) + return transformedTextLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) + ?.let { offsetMapping.mapFromTransformed(it) } } override fun closestPositionToPoint(point: DpOffset, withinRange: TextRange): Int? { - val pointOffset = - textLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) - ?: return null - return pointOffset.coerceIn(withinRange.start, withinRange.end) + val transformedTextLayoutResult = transformedTextLayoutResult ?: return null + val transformedTextOffset = + transformedTextLayoutResult.getOffsetForPosition(point.toOffset(rootView.density)) + return offsetMapping.mapFromTransformed(transformedTextOffset) + .coerceIn(withinRange.start, withinRange.end) } override fun characterRangeAtPoint(point: DpOffset): TextRange? { - val pointOffset = - textLayoutResult?.getOffsetForPosition(point.toOffset(rootView.density)) - ?: return null - return textLayoutResult?.getWordBoundary(pointOffset) + val transformedTextLayoutResult = transformedTextLayoutResult ?: return null + val transformedPointOffset = + transformedTextLayoutResult.getOffsetForPosition(point.toOffset(rootView.density)) + val wordBoundary = transformedTextLayoutResult.getWordBoundary(transformedPointOffset) + return offsetMapping.mapFromTransformed(wordBoundary) } override fun positionWithinRange( @@ -339,27 +356,46 @@ internal class NativeTextInputConnection( TextLayoutDirection.Up -> range.start TextLayoutDirection.Down -> range.end else -> { - val layout = textLayoutResult ?: return null - val startLine = layout.getLineForOffset(range.start) - val endLine = layout.getLineForOffset(range.end) + val transformedTextLayoutResult = transformedTextLayoutResult ?: return null + val mapping = offsetMapping + val transformedRange = mapping.mapToTransformed(range) + val startLine = transformedTextLayoutResult.getLineForOffset(transformedRange.start) + val endLine = transformedTextLayoutResult.getLineForOffset(transformedRange.end) val candidateOffsets = buildSet { - add(range.start) - add(range.end) + add(transformedRange.start) + add(transformedRange.end) for (line in startLine..endLine) { - add(max(range.start, layout.getLineStart(line))) - add(min(range.end, layout.getLineEnd(line))) + add( + max( + transformedRange.start, + transformedTextLayoutResult.getLineStart(line) + ) + ) + add(min(transformedRange.end, transformedTextLayoutResult.getLineEnd(line))) } } when (farthestInDirection) { TextLayoutDirection.Left -> - candidateOffsets.minByOrNull { layout.getHorizontalPosition(it, true) } + candidateOffsets.minByOrNull { + transformedTextLayoutResult.getHorizontalPosition( + it, + true + ) + } + TextLayoutDirection.Right -> - candidateOffsets.maxByOrNull { layout.getHorizontalPosition(it, true) } + candidateOffsets.maxByOrNull { + transformedTextLayoutResult.getHorizontalPosition( + it, + true + ) + } + else -> null - } + }?.let { mapping.mapFromTransformed(it) } } } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt index e41623965cb66..732a3b821e200 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/text/input/TextInputConnection.ios.kt @@ -27,8 +27,11 @@ import androidx.compose.ui.platform.NativeTextInputContextMenuCustomAction import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.SkikoUITextInputTraits import androidx.compose.ui.platform.TextEditingDelegate +import androidx.compose.ui.platform.TextEditorOffsetMapping import androidx.compose.ui.platform.getUITextInputTraits import androidx.compose.ui.platform.isValidIn +import androidx.compose.ui.platform.mapFromTransformed +import androidx.compose.ui.platform.mapToTransformed import androidx.compose.ui.platform.movePositionByGraphemes import androidx.compose.ui.scene.ComposeSceneFocusManager import androidx.compose.ui.text.TextRange @@ -75,7 +78,11 @@ internal abstract class TextInputConnection( protected var currentTextFieldValue: TextFieldValue? = null private set - protected val textLayoutResult get() = currentRequest?.textLayoutResult() + protected val transformedTextLayoutResult get() = currentRequest?.textLayoutResult() + + protected val offsetMapping: TextEditorOffsetMapping + get() = currentRequest?.offsetMapping ?: TextEditorOffsetMapping.Identity + protected val unclippedTextOffsetInRoot get() = currentRequest?.unclippedTextOffsetInRoot() protected val textFieldRectInRoot get() = currentRequest?.textFieldRectInRoot() @@ -317,15 +324,16 @@ internal abstract class TextInputConnection( override fun updateFloatingCursor(offset: DpOffset) { val translation = floatingCursorTranslation ?: return - val layout = textLayoutResult ?: return + val layout = transformedTextLayoutResult ?: return val fingerPx = offset.toOffset(rootView.density) val virtualCursorPx = fingerPx + translation - val cursorOffset = layout.getOffsetForPosition(virtualCursorPx) + val transformedCursorOffset = layout.getOffsetForPosition(virtualCursorPx) + val cursorOffset = offsetMapping.mapFromTransformed(transformedCursorOffset) // Re-anchor translation to the text edge, not the touch position — otherwise a fast // swipe past the text makes the user drag that whole distance back (and cursor looks frozen) - val line = layout.getLineForOffset(cursorOffset) + val line = layout.getLineForOffset(transformedCursorOffset) val lineLeft = layout.getLineLeft(line) val lineRight = layout.getLineRight(line) val textTop = layout.getLineTop(0) @@ -350,7 +358,8 @@ internal abstract class TextInputConnection( override fun beginFloatingCursor(offset: DpOffset) { val start = currentTextFieldValue?.selection?.start ?: return - val cursorRect = textLayoutResult?.getCursorRect(start) ?: return + val cursorRect = + transformedTextLayoutResult?.getCursorRect(offsetMapping.mapToTransformed(start)) ?: return floatingCursorTranslation = cursorRect.center - offset.toOffset(rootView.density) } @@ -442,11 +451,14 @@ internal abstract class TextInputConnection( override fun verticalPositionFromPosition(position: Int, verticalOffset: Int): Int? { val text = currentTextFieldValue?.text ?: return null - val layoutResult = textLayoutResult ?: return null + val layoutResult = transformedTextLayoutResult ?: return null - val line = layoutResult.getLineForOffset(position) + // Lines are measured in the transformed text. + val mapping = offsetMapping + val transformedPosition = mapping.mapToTransformed(position) + val line = layoutResult.getLineForOffset(transformedPosition) val lineStartOffset = layoutResult.getLineStart(line) - val offsetInLine = position - lineStartOffset + val offsetInLine = transformedPosition - lineStartOffset val targetLine = line + verticalOffset return when { targetLine < 0 -> 0 @@ -454,9 +466,9 @@ internal abstract class TextInputConnection( else -> { val targetLineEnd = layoutResult.getLineEnd(targetLine) val lineStart = layoutResult.getLineStart(targetLine) - positionFromPosition( - lineStart, min(offsetInLine, targetLineEnd - lineStart) - ) + layoutResult.layoutInput.text.text + .movePositionByGraphemes(lineStart, min(offsetInLine, targetLineEnd - lineStart)) + ?.let { mapping.mapFromTransformed(it) } } } } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt index bd34c74b3c762..5871a61e6e634 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformTextInputMethodRequest.skiko.kt @@ -17,7 +17,6 @@ package androidx.compose.ui.platform import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.text.TextLayoutResult @@ -57,6 +56,14 @@ actual interface PlatformTextInputMethodRequest { @ExperimentalComposeUiApi val textLayoutResult: () -> TextLayoutResult? + /** + * Maps ranges between the [state] text and the text laid out in [textLayoutResult]. They + * differ if the text is transformed, e.g. by a visual or an output transformation. + */ + @ExperimentalComposeUiApi + val offsetMapping: TextEditorOffsetMapping + get() = TextEditorOffsetMapping.Identity + /** * Returns the snapshot rectangle (relative to root) of the area where the actual editing * occurs. diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/TextEditorOffsetMapping.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/TextEditorOffsetMapping.skiko.kt new file mode 100644 index 0000000000000..c59615de8ad6a --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/TextEditorOffsetMapping.skiko.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.ui.platform + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.text.TextRange + +/** + * Provides bidirectional offset mapping between original and transformed text. + */ +@ExperimentalComposeUiApi +interface TextEditorOffsetMapping { + /** + * Maps a [range] in the original text to the corresponding range in the transformed text. + */ + fun mapToTransformed(range: TextRange): TextRange + + /** + * Maps a [range] in the transformed text to the corresponding range in the original text. + */ + fun mapFromTransformed(range: TextRange): TextRange + + @ExperimentalComposeUiApi + companion object { + /** The mapping for the text that isn't transformed. */ + val Identity: TextEditorOffsetMapping = object : TextEditorOffsetMapping { + override fun mapToTransformed(range: TextRange): TextRange = range + + override fun mapFromTransformed(range: TextRange): TextRange = range + } + } +} + +internal fun TextEditorOffsetMapping.mapToTransformed(offset: Int): Int = + mapToTransformed(TextRange(offset)).min + +internal fun TextEditorOffsetMapping.mapFromTransformed(offset: Int): Int = + mapFromTransformed(TextRange(offset)).min diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index 1f0531ab0a135..57ba11cf9e2d0 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.utils.BasicTextFieldType import androidx.compose.ui.test.utils.findFirstDescendant import androidx.compose.ui.test.utils.isLoupeView +import androidx.compose.ui.test.utils.runTextFieldTest import androidx.compose.ui.test.utils.up import androidx.compose.ui.test.waitForContextMenu import androidx.compose.ui.text.TextRange @@ -713,42 +714,6 @@ class TextFieldEditMenuTest { } } - @OptIn(ExperimentalFoundationApi::class) - private fun runTextFieldTest( - newContextMenuEnabled: Boolean? = null, - usingNativeTextInput: Boolean? = null, - textFieldType: BasicTextFieldType? = null, - testBlock: UIKitInstrumentedTest.( - newContextMenuEnabled: Boolean, - usingNativeTextInput: Boolean, - textFieldType: BasicTextFieldType - ) -> Unit - ) { - val oldValue = ComposeFoundationFlags.isNewContextMenuEnabled - - val newContextMenus = newContextMenuEnabled?.let(::listOf) ?: listOf(false, true) - val nativeTextInputs = usingNativeTextInput?.let(::listOf) ?: listOf(false, true) - val types = textFieldType?.let(::listOf) ?: BasicTextFieldType.entries - - val variations = newContextMenus.flatMap { menu -> - nativeTextInputs.flatMap { native -> - types.map { type -> Triple(menu, native, type) } - } - } - - try { - for ((newContextMenuEnabled, usingNativeTextInput, type) in variations) { - ComposeFoundationFlags.isNewContextMenuEnabled = newContextMenuEnabled - println("Debug: newContextMenuEnabled=$newContextMenuEnabled, usingNativeTextInput=$usingNativeTextInput, textFieldType=$type") - runUIKitInstrumentedTest { - testBlock(newContextMenuEnabled, usingNativeTextInput, type) - } - } - } finally { - ComposeFoundationFlags.isNewContextMenuEnabled = oldValue - } - } - internal class TestTextFieldState( text: String = "", selection: TextRange = TextRange.Zero, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldTransformationTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldTransformationTest.kt new file mode 100644 index 0000000000000..2af85ac4d3ba5 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldTransformationTest.kt @@ -0,0 +1,491 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DEPRECATION") + +package androidx.compose.ui.interaction + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.insert +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.allSemanticsNodes +import androidx.compose.ui.test.focusedUITextInput +import androidx.compose.ui.test.utils.BasicTextFieldType +import androidx.compose.ui.test.utils.TestTextField +import androidx.compose.ui.test.utils.TestTextFieldState +import androidx.compose.ui.test.utils.runTextFieldTest +import androidx.compose.ui.test.utils.center +import androidx.compose.ui.test.utils.leftCenter +import androidx.compose.ui.test.utils.rightCenter +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PlatformImeOptions +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.toDpRect +import androidx.compose.ui.window.NativeTextInputView +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.UIKit.UITextInputProtocol +import platform.UIKit.UITextPosition +import platform.UIKit.UITextRange +import platform.UIKit.UITextSelectionRect +import platform.UIKit.UIView + +@OptIn(ExperimentalForeignApi::class) +class TextFieldTransformationTest { + + @Test + fun testTypingFilteredCharacter() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState( + text = "12345678", + selection = TextRange(8), + textFieldType = textFieldType, + filter = { text -> text.all { it.isDigit() } } + ) + setTransformedTextFieldContent(state, nativeTextInput) + + typeWithKeyboard("a") + + assertEquals("12345678", state.text) + assertTextInputState(state, TextRange(8)) + + typeWithKeyboard("9") + + assertEquals("123456789", state.text) + assertTextInputState(state, TextRange(9)) + } + + @Test + fun testInsertTextAfterSeparator() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "12345678", textFieldType = textFieldType) + setTransformedTextFieldContent(state, nativeTextInput) + + setSelectedTextRange(TextRange(4)) + focusedUITextInput().insertText("0") + waitForIdle() + + assertEquals("123405678", state.text) + assertTextInputState(state, TextRange(5)) + } + + @Test + fun testReplaceSelectionAcrossSeparator() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "123456789", textFieldType = textFieldType) + setTransformedTextFieldContent(state, nativeTextInput) + + setSelectedTextRange(TextRange(3, 6)) + focusedUITextInput().insertText("0") + waitForIdle() + + assertEquals("1230789", state.text) + assertTextInputState(state, TextRange(4)) + } + + @Test + fun testSelectionEndingAtSeparator() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "123456789", textFieldType = textFieldType) + setTransformedTextFieldContent(state, nativeTextInput) + + // "1234| 5678 9": the separator after the selection isn't highlighted. + setSelectedTextRange(TextRange(0, 4)) + + assertTextInputState(state, TextRange(0, 4)) + assertSelectionHighlight(transformedRange = TextRange(0, 4)) + + // "1234 |5678| 9" + state.updateSelection(TextRange(5, 8)) + waitForIdle() + + assertTextInputState(state, TextRange(5, 8)) + assertSelectionHighlight(transformedRange = TextRange(6, 9)) + } + + @Test + fun testSelectionStartingAtSeparator() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "123456789", textFieldType = textFieldType) + setTransformedTextFieldContent(state, nativeTextInput) + + // "1234| 567|8 9": the separator before the selection is highlighted. + setSelectedTextRange(TextRange(4, 7)) + + assertTextInputState(state, TextRange(4, 7)) + assertSelectionHighlight(transformedRange = TextRange(4, 8)) + + // "1234| 5678| 9": only the leading separator is highlighted. + state.updateSelection(TextRange(4, 8)) + waitForIdle() + + assertTextInputState(state, TextRange(4, 8)) + assertSelectionHighlight(transformedRange = TextRange(4, 9)) + } + + @Test + fun testProgrammaticSelection() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "123456789", textFieldType = textFieldType) + setTransformedTextFieldContent(state, nativeTextInput) + + state.updateSelection(TextRange(6)) + waitForIdle() + + assertTextInputState(state, TextRange(6)) + assertSelectionHighlight(transformedRange = TextRange(7)) + + // "1|234 56|78 9" + state.updateSelection(TextRange(1, 6)) + waitForIdle() + + assertTextInputState(state, TextRange(1, 6)) + assertSelectionHighlight(transformedRange = TextRange(1, 7)) + + state.updateSelection(TextRange(9)) + waitForIdle() + + assertTextInputState(state, TextRange(9)) + assertSelectionHighlight(transformedRange = TextRange(11)) + } + + @Test + fun testShorterTextTyping() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState( + text = "12-34", + selection = TextRange(3), + textFieldType = textFieldType + ) + setShorterTextFieldContent(state, nativeTextInput) + + typeWithKeyboard("0") + + assertEquals("12-034", state.text) + assertTextInputState(state, TextRange(4)) + } + + @Test + fun testShorterTextSelectionFromTextInput() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "12-34-56", textFieldType = textFieldType) + setShorterTextFieldContent(state, nativeTextInput) + + // "12|34|56" -> "12|-34-|56": the hidden dashes are a part of the selection. + setSelectedTextRange(TextRange(2, 6)) + + assertTextInputState(state, TextRange(2, 6)) + assertSelectionHighlight(transformedRange = TextRange(2, 4)) + } + + @Test + fun testShorterTextProgrammaticSelection() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState(text = "12-34-56", textFieldType = textFieldType) + setShorterTextFieldContent(state, nativeTextInput) + + state.updateSelection(TextRange(4)) + waitForIdle() + + assertTextInputState(state, TextRange(4)) + assertSelectionHighlight(transformedRange = TextRange(3)) + + state.updateSelection(TextRange(1, 7)) + waitForIdle() + + assertTextInputState(state, TextRange(1, 7)) + assertSelectionHighlight(transformedRange = TextRange(1, 5)) + + // Only a hidden dash is selected: there is nothing to highlight. + state.updateSelection(TextRange(2, 3)) + waitForIdle() + + assertTextInputState(state, TextRange(2, 3)) + assertSelectionHighlight(transformedRange = TextRange(2)) + } + + @Test + fun testShorterTextDeleteBackward() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState( + text = "12-34", + selection = TextRange(3), + textFieldType = textFieldType + ) + setShorterTextFieldContent(state, nativeTextInput) + + // Deletes the hidden dash. + focusedUITextInput().deleteBackward() + waitForIdle() + + assertEquals("1234", state.text) + assertTextInputState(state, TextRange(2)) + } + + @Test + fun testShorterTextReplaceSelection() = runTextFieldTransformationTest { nativeTextInput, textFieldType -> + val state = TestTextFieldState( + text = "12-34-56", + selection = TextRange(1, 4), + textFieldType = textFieldType + ) + setShorterTextFieldContent(state, nativeTextInput) + + focusedUITextInput().insertText("0") + waitForIdle() + + assertEquals("104-56", state.text) + assertTextInputState(state, TextRange(2)) + } + + @Test + fun testShorterTextCaretGeometry() = runTextFieldTransformationTest( + usingNativeTextInput = true, + ) { nativeTextInput, textFieldType -> + val text = "12-34-56" + val state = TestTextFieldState(text = text, textFieldType = textFieldType) + setShorterTextFieldContent(state, nativeTextInput) + + val textInput = focusedUITextInput() + var previousCaretX = Double.NEGATIVE_INFINITY + for (offset in 0..text.length) { + val position = textInput.positionFromPosition(textInput.beginningOfDocument, offset.toLong())!! + val x = textInput.caretRectForPosition(position).useContents { origin.x + size.width / 2 } + if (offset > 0 && text[offset - 1] == DASH) { + // The dash isn't presented, so the caret doesn't move. + assertEquals(previousCaretX, x, absoluteTolerance = 0.5, "Caret at $offset") + } else { + assertTrue(x > previousCaretX, "Caret at $offset must be to the right of the previous one") + } + previousCaretX = x + } + } + + internal fun runTextFieldTransformationTest( + usingNativeTextInput: Boolean? = null, + testBlock: UIKitInstrumentedTest.( + usingNativeTextInput: Boolean, + textFieldType: BasicTextFieldType + ) -> Unit + ) = runTextFieldTest( + newContextMenuEnabled = false, // Context menu is not goal of this test + usingNativeTextInput = usingNativeTextInput, + ) { _, usingNativeTextInput, textFieldType -> + testBlock(usingNativeTextInput, textFieldType) + } + + private fun UIKitInstrumentedTest.setTransformedTextFieldContent( + state: TestTextFieldState, + nativeTextInput: Boolean, + outputTransformation: OutputTransformation = DigitGroupsOutputTransformation, + visualTransformation: VisualTransformation = DigitGroupsVisualTransformation, + ) { + val focusRequester = FocusRequester() + setContent { + Column(modifier = Modifier.safeDrawingPadding()) { + TestTextField( + state = state, + modifier = Modifier + .testTag(TEXT_FIELD_TAG) + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + // Prevents the native autocorrection from changing the text. + autoCorrectEnabled = false, + platformImeOptions = PlatformImeOptions { + usingNativeTextInput(nativeTextInput) + } + ), + inputTransformation = InputTransformation { + if (!state.filter(asCharSequence().toString())) { + revertAllChanges() + } + }, + outputTransformation = outputTransformation, + visualTransformation = visualTransformation, + ) + } + } + focusRequester.requestFocus() + waitForIdle() + } + + private fun UIKitInstrumentedTest.setShorterTextFieldContent( + state: TestTextFieldState, + nativeTextInput: Boolean, + ) = setTransformedTextFieldContent( + state = state, + nativeTextInput = nativeTextInput, + outputTransformation = NoDashesOutputTransformation, + visualTransformation = NoDashesVisualTransformation, + ) + + private fun UIKitInstrumentedTest.setSelectedTextRange(range: TextRange) { + val textInput = focusedUITextInput() + textInput.setSelectedTextRange(textInput.textRange(range)) + waitForIdle() + } + + private fun UIKitInstrumentedTest.assertSelectionHighlight(transformedRange: TextRange) { + val textInput = focusedUITextInput() + val view = textInput as UIView + val selectedRange = textInput.selectedTextRange ?: error("No selected text range") + + fun caretCenter(position: UITextPosition): DpOffset = + view.convertRect(textInput.caretRectForPosition(position), toView = null).toDpRect().center() + + var actualStart = caretCenter(selectedRange.start) + var actualEnd = caretCenter(selectedRange.end) + val rects = textInput.selectionRectsForRange(selectedRange).map { + view.convertRect((it as UITextSelectionRect).rect, toView = null).toDpRect() + } + if (transformedRange.collapsed) { + assertTrue(rects.isEmpty(), "No selection rects expected for $transformedRange, got $rects") + } else { + val rect = rects.singleOrNull() + ?: error("Single selection rect expected for $transformedRange, got $rects") + actualStart = rect.leftCenter() + actualEnd = rect.rightCenter() + } + + val (layout, layoutPositionInWindow) = focusedTextLayout() + fun expectedCaretCenter(offset: Int): DpOffset = with(density) { + val center = layoutPositionInWindow + layout.getCursorRect(offset).center + DpOffset(center.x.toDp(), center.y.toDp()) + } + + assertDpOffsetEquals(expectedCaretCenter(transformedRange.min), actualStart, "Selection start for $transformedRange") + assertDpOffsetEquals(expectedCaretCenter(transformedRange.max), actualEnd, "Selection end for $transformedRange") + } + + private fun UIKitInstrumentedTest.focusedTextLayout(): Pair { + val node = allSemanticsNodes().singleOrNull { + it.config.getOrNull(SemanticsProperties.Focused) == true && + it.config.getOrNull(SemanticsActions.GetTextLayoutResult) != null + } ?: error("No focused text field") + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(results) + val layout = results.firstOrNull() ?: error("No text layout result") + return layout to node.positionInWindow + } + + private fun assertDpOffsetEquals(expected: DpOffset, actual: DpOffset, message: String) { + assertEquals(expected.x.value, actual.x.value, absoluteTolerance = 1f, "$message: x") + assertEquals(expected.y.value, actual.y.value, absoluteTolerance = 1f, "$message: y") + } + + private fun UIKitInstrumentedTest.assertTextInputState( + state: TestTextFieldState, + selection: TextRange, + ) { + assertEquals(selection, state.selection, "Selection in the text field") + val textInput = focusedUITextInput() + assertEquals(state.text, textInput.text(TextRange(0, textInput.endOffset)), "Text presented to iOS") + assertEquals(selection, textInput.selection, "Selection presented to iOS") + } + + private val UITextInputProtocol.endOffset: Int + get() = offsetFromPosition(beginningOfDocument, endOfDocument).toInt() + + private fun UITextInputProtocol.textRange(range: TextRange): UITextRange { + val start = positionFromPosition(beginningOfDocument, range.start.toLong())!! + val end = positionFromPosition(beginningOfDocument, range.end.toLong())!! + return textRangeFromPosition(start, end)!! + } + + private fun UITextInputProtocol.text(range: TextRange): String = + textInRange(textRange(range)) ?: error("No text in $range") + + private fun UITextInputProtocol.toTextRange(range: UITextRange): TextRange = TextRange( + offsetFromPosition(beginningOfDocument, range.start).toInt(), + offsetFromPosition(beginningOfDocument, range.end).toInt() + ) + + private val UITextInputProtocol.selection: TextRange? + get() = selectedTextRange?.let { toTextRange(it) } + + private companion object { + const val TEXT_FIELD_TAG = "TextField" + const val GROUP_SIZE = 4 + const val DASH = '-' + } + + private object NoDashesOutputTransformation : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + for (index in length - 1 downTo 0) { + if (charAt(index) == DASH) { + replace(index, index + 1, "") + } + } + } + } + + private object NoDashesVisualTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val original = text.text + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + offset - original.take(offset).count { it == DASH } + + // The cursor at the hidden dashes is placed before them. + override fun transformedToOriginal(offset: Int): Int { + if (offset == 0) return 0 + var presented = 0 + original.forEachIndexed { index, char -> + if (char != DASH && ++presented == offset) return index + 1 + } + return original.length + } + } + return TransformedText(AnnotatedString(original.filterNot { it == DASH }), offsetMapping) + } + } + + private object DigitGroupsOutputTransformation : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + for (group in (length - 1) / GROUP_SIZE downTo 1) { + insert(group * GROUP_SIZE, " ") + } + } + } + + private object DigitGroupsVisualTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val offsetMapping = object : OffsetMapping { + // The cursor between groups is placed before the separator. + override fun originalToTransformed(offset: Int): Int = + offset + maxOf(0, offset - 1) / GROUP_SIZE + + override fun transformedToOriginal(offset: Int): Int = + offset - offset / (GROUP_SIZE + 1) + } + return TransformedText( + AnnotatedString(text.text.chunked(GROUP_SIZE).joinToString(" ")), + offsetMapping + ) + } + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index ecb0415cdada9..cffacc653bd28 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -1140,6 +1140,12 @@ internal fun UIKitInstrumentedTest.findFocusedUITextInput(): UITextInputProtocol } as? UITextInputProtocol } +internal fun UIKitInstrumentedTest.focusedUITextInput(): UITextInputProtocol { + val textInput = findFocusedUITextInput() + assertNotNull(textInput, "No focused text input view") + return textInput +} + internal fun UIKitInstrumentedTest.findAllUITextInputViews(): List { val windowScene = viewController.view.window?.windowScene ?: return emptyList() diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/TextField+Utils.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/TextField+Utils.kt index 053971222fc0e..4ceae1eff7e0b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/TextField+Utils.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/utils/TextField+Utils.kt @@ -16,12 +16,145 @@ package androidx.compose.ui.test.utils +import androidx.compose.foundation.ComposeFoundationFlags +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import platform.UIKit.UIView import platform.UIKit.UIWindow internal enum class BasicTextFieldType { V1, V2 } +/** + * Runs [testBlock] for every combination of the parameters. Passing a non-null parameter limits + * the run to that value only. + */ +@OptIn(ExperimentalFoundationApi::class) +internal fun runTextFieldTest( + newContextMenuEnabled: Boolean? = null, + usingNativeTextInput: Boolean? = null, + textFieldType: BasicTextFieldType? = null, + testBlock: UIKitInstrumentedTest.( + newContextMenuEnabled: Boolean, + usingNativeTextInput: Boolean, + textFieldType: BasicTextFieldType + ) -> Unit +) { + val oldValue = ComposeFoundationFlags.isNewContextMenuEnabled + + val newContextMenus = newContextMenuEnabled?.let(::listOf) ?: listOf(false, true) + val nativeTextInputs = usingNativeTextInput?.let(::listOf) ?: listOf(false, true) + val types = textFieldType?.let(::listOf) ?: BasicTextFieldType.entries + + val variations = newContextMenus.flatMap { menu -> + nativeTextInputs.flatMap { native -> + types.map { type -> Triple(menu, native, type) } + } + } + + try { + for ((newContextMenuEnabled, usingNativeTextInput, type) in variations) { + ComposeFoundationFlags.isNewContextMenuEnabled = newContextMenuEnabled + println("Debug: newContextMenuEnabled=$newContextMenuEnabled, usingNativeTextInput=$usingNativeTextInput, textFieldType=$type") + runUIKitInstrumentedTest { + testBlock(newContextMenuEnabled, usingNativeTextInput, type) + } + } + } finally { + ComposeFoundationFlags.isNewContextMenuEnabled = oldValue + } +} + +/** + * The state of [TestTextField] that works for both [BasicTextFieldType]s. + * + * @param filter accepts or rejects a new text for [BasicTextFieldType.V1]. Use + * [InputTransformation] for [BasicTextFieldType.V2]. + */ +internal class TestTextFieldState( + text: String = "", + selection: TextRange = TextRange.Zero, + val textFieldType: BasicTextFieldType, + val filter: (String) -> Boolean = { true }, +) { + val value = mutableStateOf(TextFieldValue(text, selection)) + val state = TextFieldState(text, selection) + + val text: String + get() = if (textFieldType == BasicTextFieldType.V1) value.value.text else state.text.toString() + + val selection: TextRange + get() = if (textFieldType == BasicTextFieldType.V1) value.value.selection else state.selection + + /** Changes the selection from the code, bypassing the text input. */ + fun updateSelection(selection: TextRange) { + if (textFieldType == BasicTextFieldType.V1) { + value.value = value.value.copy(selection = selection) + } else { + state.edit { this.selection = selection } + } + } + + fun isFullySelected(): Boolean = if (textFieldType == BasicTextFieldType.V1) { + value.value.selection.start == 0 && value.value.selection.end == value.value.text.length + } else { + state.selection.start == 0 && state.selection.end == state.text.length + } +} + +/** + * [BasicTextField] of the [TestTextFieldState.textFieldType]. + * + * [inputTransformation] and [outputTransformation] are applied to [BasicTextFieldType.V2] only, + * and [visualTransformation] - to [BasicTextFieldType.V1] only. + */ +@Suppress("DEPRECATION") +@Composable +internal fun TestTextField( + state: TestTextFieldState, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + readOnly: Boolean = false, + inputTransformation: InputTransformation? = null, + outputTransformation: OutputTransformation? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + if (state.textFieldType == BasicTextFieldType.V1) { + BasicTextField( + value = state.value.value, + onValueChange = { + if (state.filter(it.text)) { + state.value.value = it + } + }, + modifier = modifier, + keyboardOptions = keyboardOptions, + readOnly = readOnly, + visualTransformation = visualTransformation, + ) + } else { + BasicTextField( + state = state.state, + modifier = modifier, + keyboardOptions = keyboardOptions, + readOnly = readOnly, + inputTransformation = inputTransformation, + outputTransformation = outputTransformation, + ) + } +} + internal val loupeClassNames = listOf( "_UITextMagnifiedLoupeView", "_UITextLoupeView",