From 854cb4b403adca096d091abbf6588bc9217005cb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 17:14:32 -0400 Subject: [PATCH] Android: a hardware keyboard's arrow keys move the cursor (#6199) 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 --- .../com/fabledsword/inkwell/ui/BlockBody.kt | 16 +++++++++- .../com/fabledsword/inkwell/ui/PlainField.kt | 30 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt index 09a3c5c..514553c 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt @@ -20,7 +20,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.graphics.SolidColor import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -241,6 +245,15 @@ private fun BlockField( keyboardActions: KeyboardActions = KeyboardActions.Default, ) { 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(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) { if (hint != null && value.text.isEmpty()) { Text( @@ -252,12 +265,13 @@ private fun BlockField( BasicTextField( value = value, onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().then(arrows), enabled = enabled, singleLine = singleLine, textStyle = style, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, + onTextLayout = { layout = it }, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), ) } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/PlainField.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/PlainField.kt index a3655cc..b41ad49 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/PlainField.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/PlainField.kt @@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable 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.res.stringResource import androidx.compose.ui.text.TextStyle @@ -54,7 +56,7 @@ fun PlainTextField( TextField( value = value, onValueChange = onValueChange, - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }), enabled = enabled, placeholder = hint?.let { { Text(stringResource(it)) } }, singleLine = singleLine, @@ -86,3 +88,29 @@ private fun plainFieldColors(): TextFieldColors = unfocusedIndicatorColor = 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 + }