fix(embed): no caret handle or Cut/Paste on a readonly field

Suppressing the keyboard for a readonly field was only half of it. The
selection UI still treated it as fully editable: a tap-and-hold raised the
insertion caret handle, and its toolbar offered Cut and Paste — on a field the
page will not let you modify. Cut appeared to work in the mirror while the page
kept its text, so the two silently drifted apart.

Native, on the same page in the full-screen WebView, gives a readonly field
selection handles and a Copy / Select-all bar, and nothing else: no caret
handle (there is no caret to place) and no editing actions.

Carry the flag into SelectionUiState so the overlay can reason about it:
`fieldReadOnly` gates the insertion handle (and with it the Paste/Select-all
popup that hangs off it), and the field toolbar drops Cut and Paste. Selection,
its handles, Copy and Select-all are untouched — that half is what native
offers and it works today.

`cutSelection`/`pasteClipboard` refuse on a readonly field too. The toolbar no
longer offers them, so this is a backstop, placed next to the ops so a future
call site can't reintroduce the drift.

Device-verified: readonly long-press selects with handles and shows exactly
"Copy | Select all"; a plain tap gives no keyboard and no caret droplet; an
editable field still shows all four actions and keeps its caret handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-11 00:51:42 -04:00
co-authored by Claude Opus 5
parent 37f9c5ad85
commit b7ef983bd8
3 changed files with 39 additions and 10 deletions
@@ -377,6 +377,7 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// Cancel any in-flight scroll-hide from the page phase: otherwise the field's own
// selection-reveal scrolls keep it armed and the new field handles/toolbar never appear.
sel.scrolling = false
sel.onFieldReadOnly(event.readOnly)
sel.onFieldGeometry(event.geometry, event.text.isNotEmpty())
}
ImeEvent.WantKeyboard -> {
@@ -398,6 +399,7 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// Re-arm "the field has text" so a tap can show the insertion handle again (a tab
// switch resets it). Geometry stays null here, so this can't pop a handle up on its
// own — native shows nothing until the user touches the field.
sel.onFieldReadOnly(event.focus.readOnly)
sel.onFieldGeometry(null, event.focus.text.isNotEmpty())
}
ImeEvent.Blur -> {
@@ -529,19 +531,29 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
// In-field (<input>/<textarea>) range selection: cut/copy/paste/select-all routed to the hidden
// EditText, plus draggable start/end handles (drag → `ime.fieldextend`). The toolbar hides while a
// handle is dragged or the page scrolls; the handles hide only while scrolling.
// A readonly field offers only the non-destructive half: its text can be selected and copied, but Cut
// and Paste would silently do nothing (the page rejects the edit), so native never offers them there.
val fieldItems =
listOf(
"Cut" to {
imeView.cutSelection()
Unit
listOfNotNull(
if (sel.fieldReadOnly) {
null
} else {
"Cut" to {
imeView.cutSelection()
Unit
}
},
"Copy" to {
imeView.copySelection()
Unit
},
"Paste" to {
imeView.pasteClipboard()
Unit
if (sel.fieldReadOnly) {
null
} else {
"Paste" to {
imeView.pasteClipboard()
Unit
}
},
"Select all" to {
imeView.selectAllText()
@@ -164,9 +164,12 @@ class RemoteImeView(
fun copySelection(): Boolean = onTextContextMenuItem(android.R.id.copy)
fun cutSelection(): Boolean = onTextContextMenuItem(android.R.id.cut)
// Cut and paste mutate the field, so they are refused on a readonly one: the page would reject the edit
// and the mirror would drift out of sync with it. The toolbar already hides them there — this is the
// backstop, kept next to the ops themselves so a future call site can't reintroduce the divergence.
fun cutSelection(): Boolean = !fieldReadOnly && onTextContextMenuItem(android.R.id.cut)
fun pasteClipboard(): Boolean = onTextContextMenuItem(android.R.id.paste)
fun pasteClipboard(): Boolean = !fieldReadOnly && onTextContextMenuItem(android.R.id.paste)
fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll)
@@ -66,6 +66,14 @@ class SelectionUiState {
var fieldHasText by mutableStateOf(false)
private set
/**
* The focused field is `readonly`. Its text can still be selected and copied — that is what native offers
* — but nothing can be placed or changed in it, so it gets no insertion caret handle and no editing
* actions in the toolbar.
*/
var fieldReadOnly by mutableStateOf(false)
private set
/** Plain page-text (non-editable) selection. */
var pageSelection by mutableStateOf<ImeEvent.PageSelection?>(null)
private set
@@ -86,7 +94,7 @@ class SelectionUiState {
// ---- derived visibility (read in composition; track the backing state) ----
val insertionHandle: SelectionGeometry?
get() = if (caretShown && fieldHasText && !fieldHasRange && !scrolling) insertionGeometry else null
get() = if (caretShown && fieldHasText && !fieldReadOnly && !fieldHasRange && !scrolling) insertionGeometry else null
/** Geometry for the insertion-handle Paste/Select-All popup, or null when it shouldn't show. */
val insertionPopupAt: SelectionGeometry?
@@ -165,10 +173,16 @@ class SelectionUiState {
fieldRange = null
fieldHasRange = false
fieldHasText = false
fieldReadOnly = false
caretShown = false
insertionPopup = false
}
/** Records whether the newly focused field is `readonly` — see [fieldReadOnly]. */
fun onFieldReadOnly(readOnly: Boolean) {
fieldReadOnly = readOnly
}
fun onPageSelection(sel: ImeEvent.PageSelection?) {
pageSelection = sel
}