From 24685556b7077144c231748ff4a1496202fe2ac4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 21:00:57 -0400 Subject: [PATCH] android: open an existing note ready to keep writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a note put no cursor anywhere, so carrying on cost a tap into the body and usually a second one to drag the caret past the existing text. The compose sheet has always focused its field on open; the editor never did, and continuing a note is the more common act of the two. Focus the body on open, caret at the end. Not for a trashed note — that renders read-only and a keyboard over a record you cannot edit is noise. Keyed on note.id so the reused editor re-requests when pointed at a different note. The caret position is why the body state moves from String to TextFieldValue: a String field always starts its selection at offset zero, so focusing one lands the cursor before the first character — the wrong end of a note you meant to continue. PlainTextField gains a TextFieldValue overload for it, and the two overloads share one colours definition rather than growing a second copy of the "no box" treatment this file exists to keep in one place. I recorded this backwards in Scribe 2947 — as the keyboard opening unwanted, when the report was the opposite. The source having no FocusRequester was the tell, and I read it as a mystery instead of as evidence I had the direction wrong. Co-Authored-By: Claude Opus 5 (1M context) --- .../thoughtsync/ui/NoteEditorScreen.kt | 38 ++++++++-- .../fabledsword/thoughtsync/ui/PlainField.kt | 75 +++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index fc446e7..6e4c4a5 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -23,12 +23,17 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Label @@ -60,7 +65,15 @@ fun NoteEditorScreen( // Keyed by note id: the editor is reused across notes, and without the key the // second note opened would show the first one's text. - var body by remember(note.id) { mutableStateOf(note.body) } + // + // TextFieldValue rather than String so the CARET can start at the end of the + // text. A String field always begins its selection at offset zero, which would + // drop the cursor before the first character — the wrong place for "carry on + // writing this note", which is what opening an existing one usually means. + var body by + remember(note.id) { + mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length))) + } var picker by remember(note.id) { mutableStateOf(Picker.NONE) } var confirmingDelete by remember(note.id) { mutableStateOf(false) } @@ -74,8 +87,8 @@ fun NoteEditorScreen( // would bump `updated_at`, mark the note dirty for sync, and snapshot a // revision identical to the one before it. val flush = { - if (!readOnly && body != note.body) { - onAction(EditorAction.SaveText(body)) + if (!readOnly && body.text != note.body) { + onAction(EditorAction.SaveText(body.text)) } } val leave = { @@ -83,6 +96,18 @@ fun NoteEditorScreen( onAction(EditorAction.Close) } + // Opening an existing note means continuing it. Without this the note arrives + // unfocused, and carrying on costs a tap into the body and often a second to + // drag the caret to the end — the friction the operator reported. + // + // Not for a trashed note: it renders read-only, and a keyboard over a record + // you cannot edit is noise. `note.id` as the key so the request fires again + // when the reused editor is pointed at a different note. + val bodyFocus = remember { FocusRequester() } + LaunchedEffect(note.id) { + if (!readOnly) bodyFocus.requestFocus() + } + BackHandler(onBack = leave) // Leaving the APP is not closing the editor, so the text has to be saved @@ -151,6 +176,7 @@ fun NoteEditorScreen( hint = R.string.editor_body_hint, enabled = !readOnly, minLines = MIN_BODY_LINES, + modifier = Modifier.focusRequester(bodyFocus), ) // Below the body, not instead of it, and only once the note has items — @@ -259,15 +285,17 @@ private fun EditorOverlays( */ @Composable private fun EditorField( - value: String, - onValueChange: (String) -> Unit, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, @StringRes hint: Int, enabled: Boolean, minLines: Int = 1, + modifier: Modifier = Modifier, ) { PlainTextField( value = value, onValueChange = onValueChange, + modifier = modifier, hint = hint, enabled = enabled, minLines = minLines, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt index f78ae64..ec7c19f 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/PlainField.kt @@ -9,12 +9,14 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation /** @@ -58,18 +60,65 @@ fun PlainTextField( keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, visualTransformation = visualTransformation, - colors = - TextFieldDefaults.colors( - // Full-strength, not Material's 38%-alpha disabled treatment: a - // trashed note is rendered read-only through this field and its - // text is meant to be READ, not visually retired. - disabledTextColor = MaterialTheme.colorScheme.onSurface, - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), + colors = plainFieldColors(), ) } + +/** + * The same field over a [TextFieldValue], for the one caller that needs to control + * the SELECTION as well as the text — the editor, which opens an existing note with + * the cursor at the end so continuing to write costs no extra taps. + * + * A `String` field cannot express that: Compose starts its internal selection at + * offset zero, so focusing one drops the cursor before the first character. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlainTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + @StringRes hint: Int? = null, + enabled: Boolean = true, + singleLine: Boolean = false, + minLines: Int = 1, + textStyle: TextStyle = LocalTextStyle.current, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + enabled = enabled, + placeholder = hint?.let { { Text(stringResource(it)) } }, + singleLine = singleLine, + minLines = minLines, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + colors = plainFieldColors(), + ) +} + +/** + * One definition of "no box", shared by both overloads. Two copies of this is + * exactly the drift this file exists to prevent. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun plainFieldColors(): TextFieldColors = + TextFieldDefaults.colors( + // Full-strength, not Material's 38%-alpha disabled treatment: a trashed + // note is rendered read-only through this field and its text is meant to + // be READ, not visually retired. + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + )