android: open an existing note ready to keep writing
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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user