diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionAdjustment.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionAdjustment.kt index 4b13c599fb0b1..d724faf6f12b8 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionAdjustment.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionAdjustment.kt @@ -288,11 +288,11 @@ private fun SelectableInfo.snapToWordBoundary( return anchorForOffset(resultOffset) } -private fun interface BoundaryFunction { +internal fun interface BoundaryFunction { fun SelectableInfo.getBoundary(offset: Int): TextRange } -private fun adjustToBoundaries( +internal fun adjustToBoundaries( layout: SelectionLayout, boundaryFunction: BoundaryFunction, ): Selection { diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextFieldPointerModifier.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextFieldPointerModifier.ios.kt index 307fbc7a9a730..ab6eadc3875c1 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextFieldPointerModifier.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/TextFieldPointerModifier.ios.kt @@ -17,6 +17,7 @@ package androidx.compose.foundation.text import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.selection.IosWordSelectionAdjustment import androidx.compose.foundation.text.selection.TextFieldSelectionManager import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -40,4 +41,5 @@ internal actual fun Modifier.textFieldPointer( focusRequester, readOnly, offsetMapping, -) \ No newline at end of file + IosWordSelectionAdjustment, +) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt index 025b2deaafd27..9f10184d14a39 100644 --- a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.ios.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.text.input.internal.selection.TextFieldSelect import androidx.compose.foundation.text.input.internal.selection.TextToolbarState.Cursor import androidx.compose.foundation.text.input.internal.selection.TextToolbarState.None import androidx.compose.foundation.text.input.internal.selection.TextToolbarState.Selection +import androidx.compose.foundation.text.selection.IosWordSelectionAdjustment import androidx.compose.foundation.text.selection.MouseSelectionObserver import androidx.compose.foundation.text.selection.SelectionAdjustment import androidx.compose.foundation.text.selection.awaitSelectionGestures @@ -243,12 +244,18 @@ private fun TextFieldSelectionState.doRepeatingTapSelection( position = touchPointOffset ) + val effectiveAdjustment = + if (selectionAdjustment == SelectionAdjustment.Word) { + IosWordSelectionAdjustment + } else { + selectionAdjustment + } val newSelection = updateSelection( textFieldState.visualText, selectionOffset, selectionOffset, isStartHandle = false, - adjustment = selectionAdjustment, + adjustment = effectiveAdjustment, hapticFeedbackType = HapticFeedbackType.TextHandleMove, ) diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/IosWordSelectionAdjustment.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/IosWordSelectionAdjustment.ios.kt new file mode 100644 index 0000000000000..d669c6c97e494 --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/selection/IosWordSelectionAdjustment.ios.kt @@ -0,0 +1,48 @@ +/* + * 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.foundation.text.selection + +import androidx.compose.ui.text.TextRange +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.NaturalLanguage.NLTokenUnit +import platform.NaturalLanguage.NLTokenizer + +internal val IosWordSelectionAdjustment = SelectionAdjustment { layout -> + adjustToBoundaries(layout) { offset -> + iosWordBoundary(inputText, offset) ?: textLayoutResult.getWordBoundary(offset) + } +} + +@OptIn(ExperimentalForeignApi::class) +internal fun iosWordBoundary(text: String, offset: Int): TextRange? { + if (text.isEmpty()) return null + + val tokenizer = NLTokenizer(NLTokenUnit.NLTokenUnitWord) + tokenizer.string = text + val index = offset.coerceIn(0, text.lastIndex).toULong() + + return tokenizer.tokenRangeAtIndex(index).useContents { + if (length == 0uL || location >= text.length.toULong()) { + null + } else { + val start = location.toInt() + val end = (location + length).coerceAtMost(text.length.toULong()).toInt() + TextRange(start, end) + } + } +} diff --git a/compose/foundation/foundation/src/iosTest/kotlin/androidx/compose/foundation/text/selection/IosWordSelectionAdjustmentTest.kt b/compose/foundation/foundation/src/iosTest/kotlin/androidx/compose/foundation/text/selection/IosWordSelectionAdjustmentTest.kt new file mode 100644 index 0000000000000..4f9ab009b4bdb --- /dev/null +++ b/compose/foundation/foundation/src/iosTest/kotlin/androidx/compose/foundation/text/selection/IosWordSelectionAdjustmentTest.kt @@ -0,0 +1,49 @@ +/* + * 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.foundation.text.selection + +import androidx.compose.ui.text.TextRange +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class IosWordSelectionAdjustmentTest { + @Test + fun chineseUsesLinguisticWordBoundary() { + assertEquals(TextRange(4, 6), iosWordBoundary("我在学习中文", 5)) + } + + @Test + fun japaneseUsesLinguisticWordBoundary() { + assertEquals(TextRange(3, 5), iosWordBoundary("これは日本語です", 4)) + } + + @Test + fun cjkAfterEmojiUsesUtf16Offsets() { + assertEquals(TextRange(6, 8), iosWordBoundary("😀我在学习中文", 7)) + } + + @Test + fun latinUsesLinguisticWordBoundary() { + assertEquals(TextRange(10, 18), iosWordBoundary("this is a language", 12)) + } + + @Test + fun whitespaceFallsBackToLayoutBoundary() { + assertNull(iosWordBoundary("word word", 4)) + } +} diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt index 1c6e71845a351..2594e0ae14e22 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/CupertinoTextFieldPointerModifier.skiko.kt @@ -45,7 +45,8 @@ internal fun Modifier.cupertinoTextFieldPointer( state: LegacyTextFieldState, focusRequester: FocusRequester, readOnly: Boolean, - offsetMapping: OffsetMapping + offsetMapping: OffsetMapping, + wordSelectionAdjustment: SelectionAdjustment = SelectionAdjustment.Word, ): Modifier = if (enabled) { this .updateSelectionTouchMode { state.isInTouchMode = it } @@ -84,7 +85,14 @@ internal fun Modifier.cupertinoTextFieldPointer( state.handleState = HandleState.Cursor } } - .then(CupertinoSelectionGesturesModifierElement(manager, state, offsetMapping)) + .then( + CupertinoSelectionGesturesModifierElement( + manager, + state, + offsetMapping, + wordSelectionAdjustment, + ) + ) .pointerHoverIcon(PointerIcon.Text) } else { this @@ -94,12 +102,18 @@ private data class CupertinoSelectionGesturesModifierElement( private val manager: TextFieldSelectionManager, private val state: LegacyTextFieldState, private val offsetMapping: OffsetMapping, + private val wordSelectionAdjustment: SelectionAdjustment, ) : ModifierNodeElement() { override fun create(): CupertinoSelectionGesturesModifierNode = - CupertinoSelectionGesturesModifierNode(manager, state, offsetMapping) + CupertinoSelectionGesturesModifierNode( + manager, + state, + offsetMapping, + wordSelectionAdjustment, + ) override fun update(node: CupertinoSelectionGesturesModifierNode) { - node.update(manager, state, offsetMapping) + node.update(manager, state, offsetMapping, wordSelectionAdjustment) } override fun InspectorInfo.inspectableProperties() { @@ -111,6 +125,7 @@ private class CupertinoSelectionGesturesModifierNode( private var manager: TextFieldSelectionManager, private var state: LegacyTextFieldState, private var offsetMapping: OffsetMapping, + private var wordSelectionAdjustment: SelectionAdjustment, ) : DelegatingNode() { private val longPressDragObserver = object : TextDragObserver { var dragTotalDistance = Offset.Zero @@ -138,7 +153,13 @@ private class CupertinoSelectionGesturesModifierNode( state.onValueChange ) if (selectionAdjustment != SelectionAdjustment.None) { - manager.doRepeatingTapSelection(startPoint, selectionAdjustment) + val effectiveAdjustment = + if (selectionAdjustment == SelectionAdjustment.Word) { + wordSelectionAdjustment + } else { + selectionAdjustment + } + manager.doRepeatingTapSelection(startPoint, effectiveAdjustment) } dragBeginOffset = startPoint } @@ -196,6 +217,7 @@ private class CupertinoSelectionGesturesModifierNode( manager: TextFieldSelectionManager, state: LegacyTextFieldState, offsetMapping: OffsetMapping, + wordSelectionAdjustment: SelectionAdjustment, ) { if (this.manager != manager) { pointerInputNode.resetPointerInputHandler() @@ -203,6 +225,7 @@ private class CupertinoSelectionGesturesModifierNode( this.manager = manager this.state = state this.offsetMapping = offsetMapping + this.wordSelectionAdjustment = wordSelectionAdjustment } }