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 + }