android: open an existing note ready to keep writing
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:00:57 -04:00
co-authored by Claude Opus 5
parent 50e2d308ea
commit 24685556b7
2 changed files with 95 additions and 18 deletions
@@ -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,
@@ -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,
)