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.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.thoughtsync.R
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
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
|
// 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.
|
// 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 picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
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
|
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||||
// revision identical to the one before it.
|
// revision identical to the one before it.
|
||||||
val flush = {
|
val flush = {
|
||||||
if (!readOnly && body != note.body) {
|
if (!readOnly && body.text != note.body) {
|
||||||
onAction(EditorAction.SaveText(body))
|
onAction(EditorAction.SaveText(body.text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val leave = {
|
val leave = {
|
||||||
@@ -83,6 +96,18 @@ fun NoteEditorScreen(
|
|||||||
onAction(EditorAction.Close)
|
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)
|
BackHandler(onBack = leave)
|
||||||
|
|
||||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
// 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,
|
hint = R.string.editor_body_hint,
|
||||||
enabled = !readOnly,
|
enabled = !readOnly,
|
||||||
minLines = MIN_BODY_LINES,
|
minLines = MIN_BODY_LINES,
|
||||||
|
modifier = Modifier.focusRequester(bodyFocus),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Below the body, not instead of it, and only once the note has items —
|
// Below the body, not instead of it, and only once the note has items —
|
||||||
@@ -259,15 +285,17 @@ private fun EditorOverlays(
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditorField(
|
private fun EditorField(
|
||||||
value: String,
|
value: TextFieldValue,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (TextFieldValue) -> Unit,
|
||||||
@StringRes hint: Int,
|
@StringRes hint: Int,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
PlainTextField(
|
PlainTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier,
|
||||||
hint = hint,
|
hint = hint,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import androidx.compose.material3.LocalTextStyle
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldColors
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,18 +60,65 @@ fun PlainTextField(
|
|||||||
keyboardOptions = keyboardOptions,
|
keyboardOptions = keyboardOptions,
|
||||||
keyboardActions = keyboardActions,
|
keyboardActions = keyboardActions,
|
||||||
visualTransformation = visualTransformation,
|
visualTransformation = visualTransformation,
|
||||||
colors =
|
colors = plainFieldColors(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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