Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ internal fun determineCursorDesiredOffset(
textLayoutResult.getLineEnd(lineNumber)
}

currentText.requiresCharacterLevelCursorPlacement(offset) -> offset

@ASalavei Andrei Salavei (ASalavei) Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The range inside the requiresCharacterLevelCursorPlacement includes U+3000 IDEOGRAPHIC SPACE (Unicode Zs). The new branch runs before isWhitespaceOrPunctuation, so tapping a fullwidth space now leaves the caret on the space instead of moving to the next word.

I would consider moving this check after the currentText.isWhitespaceOrPunctuation(offset)


currentText.isWhitespaceOrPunctuation(offset) -> findNextNonWhitespaceSymbolsSubsequenceStartOffset(
offset,
currentText
Expand Down Expand Up @@ -131,4 +133,4 @@ private fun isRightEdgeTapped(textLayoutResult: TextLayoutResult, caretOffset: I
val lineNumber = textLayoutResult.getLineForOffset(caretOffset)
val lineEndOffset = textLayoutResult.getLineEnd(lineNumber)
return lineEndOffset == caretOffset
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -205,4 +220,4 @@ class CupertinoTextFieldDelegateTest : SkikoComposeTestBase() {
density = defaultDensity,
fontFamilyResolver = fontFamilyResolver
)
}
}
Loading