Android: a hardware keyboard's arrow keys move the cursor (#6199)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m22s
Android / Build the server image (push) Successful in 1s

Scribe #6199 "Android: a hardware keyboard's arrow keys move focus instead
of the cursor".

Compose's text field sends the arrow keys of any device reporting D-pad
keys to focus search before the field sees them, and a physical keyboard
reports exactly that, so left and right jumped to the neighbouring control.
Cancelling focus search in those directions hands the key back to the field.
Up and down move between a field's lines, and from its first or last line
on to the field above or below; single-line checklist rows still step row
to row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 17:14:32 -04:00
co-authored by Claude Opus 5.5
parent 19487b079a
commit 854cb4b403
2 changed files with 44 additions and 2 deletions
@@ -20,7 +20,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -28,6 +31,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
@@ -241,6 +245,15 @@ private fun BlockField(
keyboardActions: KeyboardActions = KeyboardActions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default,
) { ) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface) val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
// Kept for the arrow keys, which ask which line the caret is on. Read only when
// one is pressed, never while composing.
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
val line = { offset: Int -> layout?.getLineForOffset(offset) ?: 0 }
val arrows =
Modifier.arrowKeysMoveCaret(
atTop = { line(value.selection.end) == 0 },
atBottom = { line(value.selection.end) == (layout?.lineCount ?: 1) - 1 },
)
Box(modifier = modifier) { Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) { if (hint != null && value.text.isEmpty()) {
Text( Text(
@@ -252,12 +265,13 @@ private fun BlockField(
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().then(arrows),
enabled = enabled, enabled = enabled,
singleLine = singleLine, singleLine = singleLine,
textStyle = style, textStyle = style,
keyboardOptions = keyboardOptions, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
onTextLayout = { layout = it },
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
) )
} }
@@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -54,7 +56,7 @@ fun PlainTextField(
TextField( TextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }),
enabled = enabled, enabled = enabled,
placeholder = hint?.let { { Text(stringResource(it)) } }, placeholder = hint?.let { { Text(stringResource(it)) } },
singleLine = singleLine, singleLine = singleLine,
@@ -86,3 +88,29 @@ private fun plainFieldColors(): TextFieldColors =
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent,
) )
/**
* Arrow keys on a hardware keyboard move the caret, not the focus.
*
* Compose's text field gives the arrow keys of any device that reports D-pad keys
* to focus search before the field sees them, and a physical keyboard with arrow
* keys reports exactly that — so without this, ← and → jump to whatever sits beside
* the field instead of moving through the text. Cancelling the search in a
* direction hands the key back: the search reports that nothing moved, and the
* field takes the key as editing.
*
* ← and → never leave the field. ↑ and ↓ move between its lines, and from the first
* or last line — [atTop], [atBottom] — on to the field above or below, the way a
* caret leaves one paragraph for the next. Both are asked at the moment of the key
* press, so they can read where the caret is now.
*/
fun Modifier.arrowKeysMoveCaret(
atTop: () -> Boolean,
atBottom: () -> Boolean,
): Modifier =
focusProperties {
left = FocusRequester.Cancel
right = FocusRequester.Cancel
if (!atTop()) up = FocusRequester.Cancel
if (!atBottom()) down = FocusRequester.Cancel
}