diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/StringHelpers.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/StringHelpers.skiko.kt index 41978a0255fc4..a4811467604c4 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/StringHelpers.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/StringHelpers.skiko.kt @@ -252,6 +252,31 @@ internal fun String.isWhitespaceOrPunctuation(offset: Int): Boolean { return codePoint.isPunctuation() || codePoint.isWhitespace() } +/** + * Returns whether iOS cursor placement should remain at the character boundary instead of + * applying Cupertino's Latin word adjustment. This helper lives in `skikoMain` alongside the + * shared Cupertino cursor adjustment, but its production callers are iOS-only. + */ +internal fun String.requiresCharacterLevelCursorPlacement(index: Int): Boolean { + if (index !in indices) return false + + return when (codePointAt(index)) { + in 0x2E80..0x312F, // CJK radicals, punctuation, Hiragana, Katakana, and Bopomofo. + in 0x3130..0x318F, // Hangul compatibility jamo. + in 0x31A0..0x31FF, // Bopomofo extended and Katakana phonetic extensions. + in 0x3400..0x4DBF, // CJK unified ideographs extension A. + in 0x4E00..0x9FFF, // CJK unified ideographs. + in 0xAC00..0xD7AF, // Hangul syllables and jamo extensions. + in 0xF900..0xFAFF, // CJK compatibility ideographs. + in 0xFE30..0xFE4F, // CJK compatibility forms. + in 0xFF00..0xFFEF, // Half-width and full-width forms. + in 0x20000..0x3134F, // Supplementary CJK ideographs. + -> true + + else -> false + } +} + /** * Returns the midpoint position in the string considering Unicode symbols. * diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.skiko.kt index d286acba131e9..dff0311811c1b 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/TextFieldDelegate.skiko.kt @@ -93,6 +93,8 @@ internal fun determineCursorDesiredOffset( textLayoutResult.getLineEnd(lineNumber) } + currentText.requiresCharacterLevelCursorPlacement(offset) -> offset + currentText.isWhitespaceOrPunctuation(offset) -> findNextNonWhitespaceSymbolsSubsequenceStartOffset( offset, currentText @@ -131,4 +133,4 @@ private fun isRightEdgeTapped(textLayoutResult: TextLayoutResult, caretOffset: I val lineNumber = textLayoutResult.getLineForOffset(caretOffset) val lineEndOffset = textLayoutResult.getLineEnd(lineNumber) return lineEndOffset == caretOffset -} \ No newline at end of file +} diff --git a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CharacterLevelCursorPlacementTest.kt b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CharacterLevelCursorPlacementTest.kt new file mode 100644 index 0000000000000..c4bf866642f96 --- /dev/null +++ b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CharacterLevelCursorPlacementTest.kt @@ -0,0 +1,55 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CharacterLevelCursorPlacementTest { + @Test + fun cjkPunctuation_requiresCharacterLevelCursorPlacement() { + val text = "a\u3001\u3002\uFF01z" + + assertTrue(text.requiresCharacterLevelCursorPlacement(1)) + assertTrue(text.requiresCharacterLevelCursorPlacement(2)) + assertTrue(text.requiresCharacterLevelCursorPlacement(3)) + } + + @Test + fun cjkScriptsAndFullWidthForms_requireCharacterLevelCursorPlacement() { + assertTrue("\u4E2D".requiresCharacterLevelCursorPlacement(0)) + assertTrue("\u3042".requiresCharacterLevelCursorPlacement(0)) + assertTrue("\u30A2".requiresCharacterLevelCursorPlacement(0)) + assertTrue("\uD55C".requiresCharacterLevelCursorPlacement(0)) + assertTrue("\uFF21".requiresCharacterLevelCursorPlacement(0)) + assertTrue("\uD840\uDC00".requiresCharacterLevelCursorPlacement(0)) + } + + @Test + fun latinAsciiPunctuationAndEmoji_keepCupertinoWordPlacement() { + assertFalse("a".requiresCharacterLevelCursorPlacement(0)) + assertFalse(",".requiresCharacterLevelCursorPlacement(0)) + assertFalse("\uD83D\uDE42".requiresCharacterLevelCursorPlacement(0)) + } + + @Test + fun invalidIndex_doesNotRequireCharacterLevelCursorPlacement() { + assertFalse("\u4E2D".requiresCharacterLevelCursorPlacement(-1)) + assertFalse("\u4E2D".requiresCharacterLevelCursorPlacement(1)) + } +} diff --git a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CupertinoTextFieldDelegateTest.kt b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CupertinoTextFieldDelegateTest.kt index 0fe7e9f2ea33c..f41722f47bfdd 100644 --- a/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CupertinoTextFieldDelegateTest.kt +++ b/compose/foundation/foundation/src/skikoTest/kotlin/androidx/compose/foundation/text/CupertinoTextFieldDelegateTest.kt @@ -80,6 +80,21 @@ class CupertinoTextFieldDelegateTest : SkikoComposeTestBase() { testDetermineCursorDesiredOffset(givenOffset, desiredOffset, text) } + @Test + fun determineCursorDesiredOffset_tap_on_cjk_punctuation() { + val text = "tap\u3001" + "\u540E".repeat(120) + " html" + val punctuationOffset = text.indexOf('\u3001') + + val actual = + determineCursorDesiredOffset( + offset = punctuationOffset, + textLayoutResult = createSimpleTextLayoutResult(text), + currentText = text, + ) + + assertEquals(punctuationOffset, actual) + } + @Test fun determineCursorDesiredOffset_tap_in_the_first_half_of_word() { val givenOffset = 23 @@ -205,4 +220,4 @@ class CupertinoTextFieldDelegateTest : SkikoComposeTestBase() { density = defaultDensity, fontFamilyResolver = fontFamilyResolver ) -} \ No newline at end of file +}