From baddd5b3bd7d445e1d5aaa09f0b8d33d70b9a556 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 25 Jun 2026 10:51:49 -0400 Subject: [PATCH] fix(embed): IME typing + host-drawn text selection for embedded surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedded WebView surfaces (:napplet process, SurfaceControlViewHost) can't host the soft keyboard or present Chrome's own selection UI, so editing and selection are relayed to the main process. This lands the working set of that relay: - shim.js: fix React-controlled input erase by writing through the native HTMLInputElement/HTMLTextAreaElement value setter (so React's value tracker stays in sync); make the host authoritative for selection re-assert (the editable selectionchange handler only mirrors); report field/caret geometry and page-text selection geometry; add pageExtend + caret coords (border-width corrected) for drag-to-extend and the insertion handle. - RemoteImeView: land caret where tapped on focus (requestFocus before applying remote state); host-authoritative selection re-assert within a time window; setText only when text actually changed; wire copy/cut/paste/select-all and edit callbacks. - EmbeddedTabLayer: stop resizing the surface on IME show (removes the ~1s first-letter freeze); draw the selection overlay — toolbar, teardrop selection handles, and the insertion (cursor) handle. - EmbeddedImeBridge / Embedded{Napplet,Browser}Controller: carry selection + caret geometry and the page-selection event across the Messenger channel. Known limitation (not fixed here): after a field's page is opened in its own full-screen activity and the user returns, the selection-highlight paint stays off across all embedded surfaces. DOM selection, the toolbar, and copy still work — only the native highlight is gone. This is a WebView/Chromium behavior in off-window surfaces and is not reachable from the app layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../browser/EmbeddedWebAppController.kt | 9 + .../loggedIn/embed/EmbeddedImeBridge.kt | 56 +++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 378 +++++++++++++++++- .../ui/screen/loggedIn/embed/RemoteImeView.kt | 93 ++++- .../favorites/EmbeddedNostrAppController.kt | 9 + .../composeResources/files/napplet/shim.js | 161 +++++++- 6 files changed, 694 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 305ba16e0d..8de6744146 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry import org.json.JSONObject import java.util.concurrent.atomic.AtomicLong @@ -256,6 +257,7 @@ class EmbeddedWebAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) "ime.blur" -> ImeEvent.Blur "ime.state" -> @@ -263,6 +265,13 @@ class EmbeddedWebAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) else -> null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index 35c0375c0f..08526f460b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -20,6 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +import org.json.JSONObject + +/** Parses the `geom` object of an `ime.pagesel` payload into a [SelectionGeometry], or null if absent. */ +fun parseSelectionGeometry(o: JSONObject?): SelectionGeometry? { + if (o == null) return null + return SelectionGeometry( + left = o.optDouble("l", 0.0).toFloat(), + top = o.optDouble("t", 0.0).toFloat(), + right = o.optDouble("r", 0.0).toFloat(), + bottom = o.optDouble("b", 0.0).toFloat(), + startX = o.optDouble("sx", 0.0).toFloat(), + startBottom = o.optDouble("sb", 0.0).toFloat(), + endX = o.optDouble("ex", 0.0).toFloat(), + endBottom = o.optDouble("eb", 0.0).toFloat(), + viewportWidth = o.optDouble("vw", 0.0).toFloat(), + caretX = if (o.has("cx")) o.optDouble("cx").toFloat() else null, + caretTop = if (o.has("ct")) o.optDouble("ct").toFloat() else null, + caretBottom = if (o.has("cb")) o.optDouble("cb").toFloat() else null, + ) +} + /** * A cross-process editable: the focused field lives in the embedded WebView (a different process/window * that can't host the soft keyboard), so the main app keeps the keyboard and relays editing across this @@ -44,6 +65,7 @@ sealed interface ImeEvent { val text: String, val selStart: Int, val selEnd: Int, + val geometry: SelectionGeometry? = null, ) : ImeEvent /** The field lost focus — dismiss the keyboard. */ @@ -54,5 +76,39 @@ sealed interface ImeEvent { val text: String, val selStart: Int, val selEnd: Int, + val geometry: SelectionGeometry? = null, + ) : ImeEvent + + /** + * Ordinary page text (not an input) gained or lost a selection. The embedded WebView can't present + * Chrome's copy toolbar/handles in its cross-process surface, so the host draws its own over the page; + * [text] is the selected text to put on the clipboard, [geometry] places the toolbar + drag handles. + */ + data class PageSelection( + val active: Boolean, + val text: String, + val geometry: SelectionGeometry?, ) : ImeEvent } + +/** + * Selection geometry in the page's CSS px (viewport coords). The host scales by surface-width / [viewportWidth] + * to screen px. [left]/[top]/[right]/[bottom] is the bounding box (toolbar anchors above it); ([startX], + * [startBottom]) and ([endX], [endBottom]) are the caret feet where the two drag handles sit. + */ +data class SelectionGeometry( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, + val startX: Float, + val startBottom: Float, + val endX: Float, + val endBottom: Float, + val viewportWidth: Float, + // Present only when the field's selection is a bare caret (no range): the caret rect, so the host can + // show a draggable insertion handle below it. Null for a range or page-text selection. + val caretX: Float? = null, + val caretTop: Float? = null, + val caretBottom: Float? = null, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index cf79f727da..b0df00d15f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -20,19 +20,34 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.os.Build import android.view.ViewGroup import androidx.annotation.RequiresApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -40,18 +55,26 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.privacysandbox.ui.client.view.SandboxedSdkView import kotlinx.coroutines.delay +import org.json.JSONObject +import kotlin.math.roundToInt // How far off-screen a parked (inactive) warm tab is shifted — well past any real screen width. private val OFFSCREEN_SHIFT = 10_000.dp @@ -121,10 +144,15 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // surface re-render, no black flash between tabs. (Parking at 1dp forced a // resize + re-render on every switch, which flashed black for ~1s.) val left = (bounds.left - layerOrigin.x).toDp() + (if (active) 0.dp else OFFSCREEN_SHIFT) - val imeOverlap = if (active) (imeBottomPx - (layerOrigin.y + layerSize.height - bounds.bottom)).coerceAtLeast(0f) else 0f + // Do NOT shrink the surface for the keyboard: resizing reconfigures the cross-process + // SurfaceControlViewHost surface, and the first frame presented after that reconfigure + // stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring + // the focused field above the keyboard via the shim's scrollIntoView on focus. + @Suppress("UNUSED_EXPRESSION") + imeBottomPx Modifier .absoluteOffset(left, (bounds.top - layerOrigin.y).toDp()) - .size(bounds.width.toDp(), (bounds.height - imeOverlap).coerceAtLeast(1f).toDp()) + .size(bounds.width.toDp(), bounds.height.toDp()) } } else { // No content bounds reported yet: park tiny off-screen until a tab is shown. @@ -267,17 +295,47 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { val context = LocalContext.current val imeBridge = EmbeddedTabHost.sessions.firstOrNull { it.id == activeId }?.controller as? EmbeddedImeBridge val imeView = remember { RemoteImeView(context) } + // The embedded WebView can't show Chrome's copy/paste toolbar in its cross-process surface, so when + // the page has a non-empty selection we show our own here, over the page, and route its actions to + // the hidden EditText (which mirrors the selection) — see [RemoteImeView.onRangeSelectionChanged]. + var showSelectionToolbar by remember { mutableStateOf(false) } + var fieldGeometry by remember { mutableStateOf(null) } + // Insertion (cursor) handle visibility: shown when a tap places/moves a bare caret, hidden while + // typing — like Android. A caret-bearing geometry update means a tap; an edit means typing. + var showInsertionHandle by remember { mutableStateOf(false) } + var pageSelection by remember { mutableStateOf(null) } DisposableEffect(imeBridge) { imeView.bind(imeBridge) + imeView.onRangeSelectionChanged = { showSelectionToolbar = it } + imeView.onEdited = { showInsertionHandle = false } imeBridge?.onImeEvent = { event -> when (event) { - is ImeEvent.Focus -> imeView.onPageFocus(event) - ImeEvent.Blur -> imeView.onPageBlur() - is ImeEvent.State -> imeView.onPageState(event) + is ImeEvent.Focus -> { + imeView.onPageFocus(event) + event.geometry?.let { fieldGeometry = it } + if (event.geometry?.caretX != null) showInsertionHandle = true + } + ImeEvent.Blur -> { + imeView.onPageBlur() + fieldGeometry = null + showInsertionHandle = false + } + is ImeEvent.State -> { + imeView.onPageState(event) + event.geometry?.let { fieldGeometry = it } + if (event.geometry?.caretX != null) showInsertionHandle = true + } + is ImeEvent.PageSelection -> pageSelection = event.takeIf { it.active } } } onDispose { imeBridge?.onImeEvent = null + imeView.onRangeSelectionChanged = null + imeView.onEdited = null + showSelectionToolbar = false + fieldGeometry = null + showInsertionHandle = false + pageSelection = null imeView.onPageBlur() imeView.bind(null) } @@ -293,5 +351,315 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { ).size(1.dp), ) } + + // Input-field selection: cut/copy/paste/select-all routed to the hidden EditText. The DOM exposes no + // rect for a selection *inside* an input, so we anchor the bar above the field box (reported by the + // shim), falling back to the top of the tab if the field geometry is missing. + if (showSelectionToolbar && bounds.width > 0f && bounds.height > 0f) { + val fg = fieldGeometry + val items = + listOf( + "Cut" to { + imeView.cutSelection() + Unit + }, + "Copy" to { + imeView.copySelection() + Unit + }, + "Paste" to { + imeView.pasteClipboard() + Unit + }, + "Select all" to { + imeView.selectAllText() + Unit + }, + ) + val originX = bounds.left - layerOrigin.x + val originY = bounds.top - layerOrigin.y + val toolbarH = with(density) { 44.dp.toPx() } + val gap = with(density) { 8.dp.toPx() } + if (fg != null && fg.viewportWidth > 0f) { + val scale = bounds.width / fg.viewportWidth + val topPx = originY + fg.top * scale + val toolbarY = if (topPx - toolbarH - gap > originY) topPx - toolbarH - gap else originY + fg.bottom * scale + gap + EmbeddedSelectionToolbar( + items = items, + centerXpx = originX + ((fg.left + fg.right) / 2f) * scale, + topYpx = toolbarY, + ) + } else { + EmbeddedSelectionToolbar( + items = items, + centerXpx = originX + bounds.width / 2f, + topYpx = originY + gap, + ) + } + } + + // Bare caret in a field (no range): a draggable insertion handle under the cursor, like Android's. + val fgCaret = fieldGeometry + if (showInsertionHandle && !showSelectionToolbar && fgCaret?.caretX != null && fgCaret.caretBottom != null && + fgCaret.viewportWidth > 0f && bounds.width > 0f && bounds.height > 0f + ) { + val scale = bounds.width / fgCaret.viewportWidth + val originX = bounds.left - layerOrigin.x + val originY = bounds.top - layerOrigin.y + InsertionHandle( + tipPx = Offset(originX + fgCaret.caretX * scale, originY + fgCaret.caretBottom * scale), + surfaceOriginX = originX, + surfaceOriginY = originY, + scale = scale, + onDragTo = { cssX, cssY -> + imeBridge?.sendImeOp( + JSONObject() + .put("type", "ime.caretmove") + .put("x", cssX.toDouble()) + .put("y", cssY.toDouble()) + .toString(), + ) + }, + ) + } + + // Plain page-text selection: a Copy bar positioned over the selection + a drag handle at each end. + val pageSel = pageSelection + val geom = pageSel?.geometry + if (geom != null && geom.viewportWidth > 0f && bounds.width > 0f && bounds.height > 0f) { + PageSelectionOverlay( + geometry = geom, + surfaceOriginX = bounds.left - layerOrigin.x, + surfaceOriginY = bounds.top - layerOrigin.y, + scale = bounds.width / geom.viewportWidth, + onCopy = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("selection", pageSel.text)) + }, + onExtend = { edge, cssX, cssY -> + imeBridge?.sendImeOp( + JSONObject() + .put("type", "ime.pageextend") + .put("edge", edge) + .put("x", cssX.toDouble()) + .put("y", cssY.toDouble()) + .toString(), + ) + }, + ) + } } } + +/** + * Host-drawn selection overlay for a plain page-text selection: a Copy bar above the selection plus a + * draggable handle at each end. [geometry] is in the page's CSS px; [scale] and the surface origin map those + * to this layer's px. Dragging a handle reports the new CSS-px target via [onExtend], which the shim turns + * into a selection extension; the resulting geometry update repositions everything. + */ +@Composable +private fun PageSelectionOverlay( + geometry: SelectionGeometry, + surfaceOriginX: Float, + surfaceOriginY: Float, + scale: Float, + onCopy: () -> Unit, + onExtend: (edge: String, cssX: Float, cssY: Float) -> Unit, +) { + val density = LocalDensity.current + + fun mapX(css: Float) = surfaceOriginX + css * scale + + fun mapY(css: Float) = surfaceOriginY + css * scale + + val toolbarH = with(density) { 44.dp.toPx() } + val gap = with(density) { 8.dp.toPx() } + val topPx = mapY(geometry.top) + val toolbarY = if (topPx - toolbarH - gap > surfaceOriginY) topPx - toolbarH - gap else mapY(geometry.bottom) + gap + + EmbeddedSelectionToolbar( + items = listOf("Copy" to onCopy), + centerXpx = mapX((geometry.left + geometry.right) / 2f), + topYpx = toolbarY, + ) + + SelectionHandle(Offset(mapX(geometry.startX), mapY(geometry.startBottom)), isStart = true, surfaceOriginX, surfaceOriginY, scale) { x, y -> onExtend("start", x, y) } + SelectionHandle(Offset(mapX(geometry.endX), mapY(geometry.endBottom)), isStart = false, surfaceOriginX, surfaceOriginY, scale) { x, y -> onExtend("end", x, y) } +} + +/** + * A draggable selection-endpoint handle drawn as Android's teardrop: a disc with one squared corner that + * points to the caret. The start handle points up-right (hangs to the left of the selection start); the end + * handle points up-left. [tipPx] is the caret foot (this layer's px) where the point sits; a drag reports + * the new tip position in the page's CSS px. + */ +@Composable +private fun SelectionHandle( + tipPx: Offset, + isStart: Boolean, + surfaceOriginX: Float, + surfaceOriginY: Float, + scale: Float, + onDragTo: (cssX: Float, cssY: Float) -> Unit, +) { + val sizeDp = 22.dp + val sizePx = with(LocalDensity.current) { sizeDp.toPx() } + val color = MaterialTheme.colorScheme.primary + val currentTip by rememberUpdatedState(tipPx) + var dragTip by remember { mutableStateOf(null) } + val tip = dragTip ?: tipPx + // Place the box so its pointed corner lands on the tip: start = top-right corner, end = top-left corner. + val boxLeft = if (isStart) tip.x - sizePx else tip.x + Box( + Modifier + .absoluteOffset { IntOffset(boxLeft.roundToInt(), tip.y.roundToInt()) } + .size(sizeDp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { dragTip = currentTip }, + onDrag = { change, delta -> + change.consume() + val np = (dragTip ?: currentTip) + delta + dragTip = np + onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale) + }, + onDragEnd = { dragTip = null }, + onDragCancel = { dragTip = null }, + ) + }, + ) { + Canvas(Modifier.fillMaxSize()) { + val d = size.minDimension + val path = + Path().apply { + if (isStart) { + moveTo(d, 0f) + lineTo(d, d / 2f) + arcTo(Rect(0f, 0f, d, d), 0f, 270f, false) + close() + } else { + moveTo(0f, 0f) + lineTo(0f, d / 2f) + arcTo(Rect(0f, 0f, d, d), 180f, -270f, false) + close() + } + } + drawPath(path, color) + } + } +} + +/** + * A draggable insertion (cursor) handle drawn as Android's upward teardrop, hanging below a bare caret with + * its tip on the caret. [tipPx] is the caret foot (this layer's px); a drag reports the new caret position in + * the page's CSS px, which the shim maps back to a character offset. + */ +@Composable +private fun InsertionHandle( + tipPx: Offset, + surfaceOriginX: Float, + surfaceOriginY: Float, + scale: Float, + onDragTo: (cssX: Float, cssY: Float) -> Unit, +) { + val density = LocalDensity.current + val wPx = with(density) { 20.dp.toPx() } + val hPx = wPx * 1.4f + val color = MaterialTheme.colorScheme.primary + val currentTip by rememberUpdatedState(tipPx) + // Track the finger separately from what we draw: the handle renders at the AUTHORITATIVE caret (tipPx, + // which snaps to a character position as the move round-trips through the shim), while the finger drives + // the move. So the handle stays glued to the line/text and clamps to the field instead of trailing off. + var fingerPx by remember { mutableStateOf(null) } + Box( + Modifier + .absoluteOffset { IntOffset((tipPx.x - wPx / 2f).roundToInt(), tipPx.y.roundToInt()) } + .size(with(density) { wPx.toDp() }, with(density) { hPx.toDp() }) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { fingerPx = currentTip }, + onDrag = { change, delta -> + change.consume() + val np = (fingerPx ?: currentTip) + delta + fingerPx = np + onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale) + }, + onDragEnd = { fingerPx = null }, + onDragCancel = { fingerPx = null }, + ) + }, + ) { + Canvas(Modifier.fillMaxSize()) { + val r = size.width / 2f + val cx = size.width / 2f + val cy = size.height - r + val path = + Path().apply { + moveTo(cx, 0f) + lineTo(cx + r, cy) + arcTo(Rect(cx - r, cy - r, cx + r, cy + r), 0f, 180f, false) + lineTo(cx, 0f) + close() + } + drawPath(path, color) + } + } +} + +/** + * Host-drawn copy/paste bar for an embedded page selection. Chrome can't present its own action-mode in the + * cross-process surface, so this floats over the active tab; each action runs on the hidden [RemoteImeView] + * (which mirrors the page's text + selection), so cut/paste relay back to the page through the normal path. + * Uses pointer-input rather than `clickable` so tapping it doesn't pull focus off the EditText (which would + * drop the keyboard and the selection). + */ +@Composable +private fun EmbeddedSelectionToolbar( + items: List Unit>>, + centerXpx: Float, + topYpx: Float, +) { + // Self-center: the toolbar's width depends on its items, so measure it and offset by half-width around + // the requested centre-x (a one-frame left-aligned flash before the first measurement is acceptable). + var widthPx by remember { mutableStateOf(0) } + Surface( + modifier = + Modifier + .absoluteOffset { IntOffset((centerXpx - widthPx / 2f).roundToInt(), topYpx.roundToInt()) } + .onGloballyPositioned { widthPx = it.size.width }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + tonalElevation = 3.dp, + shadowElevation = 6.dp, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + items.forEachIndexed { i, (label, action) -> + if (i > 0) { + Box( + Modifier + .width(1.dp) + .height(20.dp) + .background(MaterialTheme.colorScheme.outlineVariant), + ) + } + SelectionToolbarItem(label, action) + } + } + } +} + +@Composable +private fun SelectionToolbarItem( + label: String, + onClick: () -> Unit, +) { + Text( + text = label, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .pointerInput(label) { detectTapGestures(onTap = { onClick() }) } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 840a5e0ce7..6c2b8d0379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed import android.annotation.SuppressLint import android.content.Context +import android.os.SystemClock import android.text.Editable import android.text.InputType import android.text.TextWatcher @@ -93,7 +94,10 @@ class RemoteImeView( count: Int, ) {} - override fun afterTextChanged(s: Editable?) = schedule() + override fun afterTextChanged(s: Editable?) { + if (!applyingRemote) onEdited?.invoke() + schedule() + } }, ) // The IME's "Go/Search/Send/Done" — the page submits/handles it (single-line has no newline). @@ -108,26 +112,85 @@ class RemoteImeView( this.bridge = bridge } + /** + * Notified when the mirrored selection becomes (true) or stops being (false) a non-empty range. The + * embedded WebView can't present Chrome's copy/paste toolbar in its cross-process surface, so the host + * shows its own over the page and routes the actions back through [copy]/[cut]/[paste]/[selectAll], + * which run on this EditText's Editable (and so relay to the page through the normal edit path). + */ + var onRangeSelectionChanged: ((Boolean) -> Unit)? = null + private var hadRange = false + + /** Fired when the user edits text via the keyboard (not on programmatic page-state applies). The host + * hides the insertion handle while typing, the way Android does. */ + var onEdited: (() -> Unit)? = null + + fun copySelection(): Boolean = onTextContextMenuItem(android.R.id.copy) + + fun cutSelection(): Boolean = onTextContextMenuItem(android.R.id.cut) + + fun pasteClipboard(): Boolean = onTextContextMenuItem(android.R.id.paste) + + fun selectAllText(): Boolean = onTextContextMenuItem(android.R.id.selectAll) + /** A page field focused: configure the keyboard, seed the buffer, and raise the IME. */ fun onPageFocus(focus: ImeEvent.Focus) { configureFor(focus) - applyRemote(focus.text, focus.selStart, focus.selEnd) + // Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it + // gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the + // page — so a tap mid-text lands the caret at the end of the field. Seeding AFTER focus makes the + // tap position the final state (the focus-induced end-position only schedules a flush that then + // coalesces to this seed, a no-op). requestFocus() imm.restartInput(this) + applyRemote(focus.text, focus.selStart, focus.selEnd) // Post the show so it runs after focus/attachment has settled (showSoftInput can no-op otherwise). post { if (hasFocus()) imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } } + // When the current selection first became a range, and how many of its collapse-abandonments we've + // re-asserted. Chrome abandons a selection *immediately* (~60ms); a deliberate user tap-to-collapse comes + // later — so we only re-assert within a short window of the range forming, bounded for safety. + private var rangeBecameAt = 0L + private var reassertCount = 0 + /** The page changed the field itself (its JS, autofill): mirror it without echoing back. */ fun onPageState(state: ImeEvent.State) { - if (text?.toString() == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return + val curText = text?.toString() ?: "" + // Chrome can't present selection handles in the embedded surface, so it abandons a selection by + // collapsing the caret to one of the range's endpoints, right after it forms. We hold the + // authoritative selection here, so a collapse to an endpoint of our current range (same text) within + // the window is that abandonment, not a user action: re-assert our range instead of accepting it. + val collapsedToEndpoint = + selectionStart != selectionEnd && + state.selStart == state.selEnd && + state.text == curText && + (state.selStart == selectionStart || state.selStart == selectionEnd) + if (collapsedToEndpoint && reassertCount < MAX_REASSERT && + SystemClock.uptimeMillis() - rangeBecameAt < REASSERT_WINDOW_MS + ) { + reassertCount++ + // Keep the window alive across the fight: Chrome re-abandons the selection every ~600ms while the + // gesture is held, so each re-assert restarts the clock — bounded by reassertCount so a page that + // truly keeps the caret collapsed still wins. + rangeBecameAt = SystemClock.uptimeMillis() + lastSent = null // force a non-no-op flush so the range re-ships + flushState() + return + } + reassertCount = 0 + if (curText == state.text && selectionStart == state.selStart && selectionEnd == state.selEnd) return applyRemote(state.text, state.selStart, state.selEnd) } /** The page field blurred: drop the keyboard. */ fun onPageBlur() { + if (hadRange) { + hadRange = false + onRangeSelectionChanged?.invoke(false) + } clearFocus() imm.hideSoftInputFromWindow(windowToken, 0) } @@ -138,11 +201,18 @@ class RemoteImeView( selEnd: Int, ) { applyingRemote = true - setText(newText) + // Only replace the buffer when the text actually changed. A page that rewrites its field's + // selection on a timer (without changing the text) would otherwise force a full setText on every + // update — clearing the composing region and restarting the IME — which needlessly churns the + // keyboard and contends with the user's own typing. Reposition the cursor without touching the buffer. + if (text?.toString() != newText) setText(newText) val len = text?.length ?: 0 setSelection(selStart.coerceIn(0, len), selEnd.coerceIn(0, len)) applyingRemote = false lastSent = stateJson().toString() + // Start the abandonment window when a fresh range appears, so onPageState can tell Chrome's instant + // collapse from a later user tap-to-collapse. + if (selectionStart != selectionEnd) rangeBecameAt = SystemClock.uptimeMillis() } private fun schedule() { @@ -156,6 +226,11 @@ class RemoteImeView( selEnd: Int, ) { super.onSelectionChanged(selStart, selEnd) + val isRange = selStart != selEnd + if (isRange != hadRange) { + hadRange = isRange + onRangeSelectionChanged?.invoke(isRange) + } schedule() } @@ -237,4 +312,14 @@ class RemoteImeView( return result } } + + private companion object { + // Chrome re-abandons a held selection repeatedly; cover a long-press hold (each re-assert restarts the + // window) while still giving up if the page truly keeps re-collapsing forever. + private const val MAX_REASSERT = 12 + + // Wider than Chrome's ~600ms re-collapse interval so consecutive abandonments stay inside the window, + // yet short enough that a deliberate user tap-to-collapse (well after the gesture) is accepted. + private const val REASSERT_WINDOW_MS = 800L + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 53cb90bfe9..a4198dd5db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry import org.json.JSONObject import java.util.concurrent.atomic.AtomicLong @@ -201,6 +202,7 @@ class EmbeddedNostrAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) "ime.blur" -> ImeEvent.Blur "ime.state" -> @@ -208,6 +210,13 @@ class EmbeddedNostrAppController( text = o.optString("text", ""), selStart = o.optInt("selStart", 0), selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + "ime.pagesel" -> + ImeEvent.PageSelection( + active = o.optBoolean("active", false), + text = o.optString("text", ""), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), ) else -> null } diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index edba01974e..c08c9f2e31 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -191,6 +191,7 @@ if (IME_PROXY) (function(){ var el = null; // the focused editable element, or null var inComposition = false; + function perfNow(){ try { return performance.now(); } catch (_) { return 0; } } function isEditable(n){ if (!n) return false; @@ -205,7 +206,22 @@ } function isCE(n){ return !!(n && n.isContentEditable); } function valOf(n){ return isCE(n) ? n.textContent : (n.value || ''); } - function setVal(n, v){ if (isCE(n)) n.textContent = v; else n.value = v; } + // Controlled-input frameworks (React, Preact, …) install an INSTANCE-level `value` setter on the + // /