Merge pull request #2597 from davotoula/fix-chat-cursor-jump

Fix chat cursor jump
This commit is contained in:
Vitor Pamplona
2026-04-27 08:07:50 -04:00
committed by GitHub
12 changed files with 313 additions and 135 deletions
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
@file:OptIn(ExperimentalFoundationApi::class)
package com.vitorpamplona.amethyst
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldState
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Drives [MentionPreservingInputTransformation] against a real [TextFieldState]
* with simulated IME edits. The npub literal has no metadata loaded — these
* tests only exercise the input-side guard, which keys off the underlying bech32
* text rather than any display-name resolution.
*/
@RunWith(AndroidJUnit4::class)
class MentionPreservingInputTransformationTest {
/** 64 characters: leading `@` + bech32 (`npub1` + 58 chars). */
private val npub = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
/**
* Apply [stage] inside an edit session, then run the InputTransformation
* exactly as the framework would, and return the committed text.
*/
private fun TextFieldState.applyChange(stage: TextFieldBuffer.() -> Unit): String {
edit {
stage()
with(MentionPreservingInputTransformation) {
transformInput()
}
}
return text.toString()
}
@Test
fun mentionFreeText_passesThrough() {
val state = TextFieldState("hello world")
val result = state.applyChange { replace(0, 5, "HELLO") }
assertEquals("HELLO world", result)
}
@Test
fun pureDeleteFullyCoveringMention_passesThrough() {
val state = TextFieldState(npub)
val result = state.applyChange { replace(0, npub.length, "") }
assertEquals("", result)
}
@Test
fun partialDeleteInsideMention_collapsesAtomically() {
val state = TextFieldState(npub)
// delete a chunk near the end of the bech32
val result = state.applyChange { replace(60, npub.length, "") }
assertEquals("", result)
}
@Test
fun partialDeleteAtMentionStart_collapsesAtomically() {
val state = TextFieldState(npub)
// delete the leading "@npub" prefix only
val result = state.applyChange { replace(0, 5, "") }
assertEquals("", result)
}
@Test
fun scopeExactReplaceWithNonEmpty_collapsesAtomically() {
// SwiftKey case: IME fully covers the mention range and writes a
// shortened replacement (e.g. one of the multi-word display tokens).
val state = TextFieldState(npub)
val result = state.applyChange { replace(0, npub.length, "@John") }
assertEquals("", result)
}
@Test
fun scopeBroaderReplace_passesThrough() {
// Select-all + type: change covers the mention plus surrounding text.
// Treated as a deliberate broader edit; the typed character is preserved.
val state = TextFieldState("hi $npub world")
val result = state.applyChange { replace(0, length, "x") }
assertEquals("x", result)
}
@Test
fun appendAfterMention_passesThrough() {
val state = TextFieldState(npub)
val result = state.applyChange { append(" hello") }
assertEquals("$npub hello", result)
}
@Test
fun mentionWithTrailingSpace_collapseConsumesSpace() {
val state = TextFieldState("$npub hello")
val result = state.applyChange { replace(60, npub.length, "") }
assertEquals("hello", result)
}
@Test
fun mentionWithTrailingNewline_collapseConsumesNewline() {
val state = TextFieldState("$npub\nhello")
val result = state.applyChange { replace(60, npub.length, "") }
assertEquals("hello", result)
}
@Test
fun multipleMentions_partialOnSecond_onlySecondCollapsed() {
val text = "$npub and $npub"
val state = TextFieldState(text)
// partial delete inside the second mention only
val result = state.applyChange { replace(text.length - 4, text.length, "") }
assertEquals("$npub and ", result)
}
@Test
fun mentionFreeChange_skipsRegexEntirely() {
// No "npub1" or "nprofile1" substring in the original text — the
// cheap-gate path should exit before any regex work.
val state = TextFieldState("hello world this is plain text")
val result = state.applyChange { replace(5, 11, "") }
assertEquals("hello this is plain text", result)
}
}
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TransformedText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
@@ -49,28 +48,6 @@ class UrlUserTagTransformationTest {
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
fun debugCursor(
original: String,
transformedText: TransformedText,
offset: Int,
): String {
val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset)
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
return "$originalWithCursor $transformedWithCursor"
}
fun debugCursorReverse(
original: String,
transformedText: TransformedText,
offsetTransformed: Int,
): String {
val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed)
val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length)
val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length)
return "$originalWithCursor $transformedWithCursor"
}
@Test
fun testKeepTransformedIndexFullyInsideTransformedText() {
val user =
@@ -103,91 +80,21 @@ class UrlUserTagTransformationTest {
val expected = "@Vitor Pamplona"
assertEquals(expected, transformedText.text.text)
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0))
assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1))
assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2))
assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3))
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4))
assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5))
assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6))
assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7))
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8))
assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9))
assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10))
assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11))
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12))
assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13))
assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14))
assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15))
assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16))
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17))
assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18))
assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19))
assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20))
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21))
assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22))
assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23))
assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24))
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25))
assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26))
assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27))
assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28))
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64))
assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0))
assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1))
assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2))
assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3))
assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4))
assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5))
assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6))
assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14))
assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15))
// The mention is treated as an atomic wedge: any cursor strictly inside the
// underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona"
// (and vice versa). This prevents an IME from placing the cursor in the middle
// of the bech32 and corrupting it on backspace.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0))
for (i in 1..63) {
assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i))
}
assertEquals(15, transformedText.offsetMapping.originalToTransformed(64))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
for (i in 1..14) {
assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i))
}
assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15))
}
@Test
@@ -219,23 +126,30 @@ class UrlUserTagTransformationTest {
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
// Outside the wedge: identity mapping.
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary)
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71))
// Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
// End-of-wedge boundary maps to end of displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
// Outside the wedge in displayed: identity.
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary)
// Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22))
// End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas.
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@@ -272,26 +186,40 @@ class UrlUserTagTransformationTest {
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
assertEquals(8, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(8, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
// Strictly inside the first underlying npub [8, 72): snap to end of first
// displayed "@Vitor Pamplona" (offset 23).
assertEquals(23, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z
// Boundary at end of first wedge: end of first displayed mention.
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge)
assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
// Strictly inside the second underlying npub [77, 141): snap to end of second
// displayed "@Vitor Pamplona" (offset 43).
assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(43, transformedText.offsetMapping.originalToTransformed(140))
// Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of
// first underlying npub (offset 72).
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display)
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge)
// Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of
// second underlying npub (offset 141).
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29))
assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42))
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
/**
* Matches a complete Nostr mention token: `@npub1…`, `nostr:npub1…`,
* `@nprofile1…`, `nostr:nprofile1…`. Shared with [UrlUserTagOutputTransformation]
* so the wedge it produces and the input-side guard below agree on what
* counts as a mention.
*/
internal val MENTION_REGEX = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
/**
* Keeps Nostr mentions atomic against IME edits that would only modify part of
* the underlying bech32 (notably Microsoft SwiftKey, which re-enters word-edit
* mode over a previously-committed display token and rewrites a single word of
* a multi-word `@DisplayName`, leaving an orphan tail of the npub that no
* longer matches the mention regex).
*
* Three change shapes are blocked and routed to atomic-collapse:
* - Partial overlap (the change's `originalRange` overlaps a mention but does
* not fully cover it).
* - Scope-exact replace (the change's `originalRange` matches the mention's
* range exactly and the replacement is non-empty — covers IMEs that
* fully-cover-replace a multi-word display token with one of its words).
*
* Anything else passes through:
* - Pure delete that fully covers a mention (the user removed the chip).
* - A change whose range covers more than just the mention (select-all + type,
* select-paragraph + paste, etc.) — treated as deliberate broader edit.
*/
@OptIn(ExperimentalFoundationApi::class)
object MentionPreservingInputTransformation : InputTransformation {
override fun TextFieldBuffer.transformInput() {
val changeCount = changes.changeCount
if (changeCount == 0) return
val original = originalText
// Cheap gate — most keystrokes happen in mention-free text.
if (!original.contains("npub1") && !original.contains("nprofile1")) return
val touched =
MENTION_REGEX.findAll(original).firstOrNull { match ->
val mStart = match.range.first
val mEndExclusive = match.range.last + 1
(0 until changeCount).any { i ->
val origRange = changes.getOriginalRange(i)
val origStart = origRange.min
val origEnd = origRange.max
val overlaps = origStart < mEndExclusive && origEnd > mStart
val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive
val isScopeExact = origStart == mStart && origEnd == mEndExclusive
val isPureDelete = changes.getRange(i).length == 0
overlaps && (!fullyCovers || (isScopeExact && !isPureDelete))
}
} ?: return
revertAllChanges()
val mEndExclusive = touched.range.last + 1
val deleteEnd =
if (mEndExclusive < length && asCharSequence()[mEndExclusive].isWhitespace()) {
mEndExclusive + 1
} else {
mEndExclusive
}
replace(touched.range.first, deleteEnd, "")
}
}
@@ -35,11 +35,8 @@ class UrlUserTagOutputTransformation(
override fun TextFieldBuffer.transformOutput() {
val text = asCharSequence().toString()
// Find all user mentions using regex and replace in reverse order
// so that earlier indices remain valid after replacements.
// Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1...
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
val matches = mentionRegex.findAll(text).toList().reversed()
// Reverse so earlier indices remain valid after each replace.
val matches = MENTION_REGEX.findAll(text).toList().reversed()
// Phase 1: Replace all mentions (reverse order keeps indices valid for replace).
// Collect replacement info because addStyle must be called after all text mutations.
@@ -138,14 +138,18 @@ fun buildAnnotatedStringWithUrlHighlighting(
val numberOffsetTranslator =
object : OffsetMapping {
// Treat each substitution as an atomic wedge: any cursor position that falls
// strictly inside a substituted range snaps to the wedge's trailing edge.
// Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the
// cursor in the middle of an "@npub1..." mention, and a subsequent backspace
// deletes a char from inside the bech32, breaking the npub and "expanding"
// the collapsed mention.
override fun originalToTransformed(offset: Int): Int {
val inInsideRange =
substitutions.firstOrNull { offset > it.original.start && offset < it.original.end }
if (inInsideRange != null) {
val percentInRange =
(offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt()
return inInsideRange.modified.end
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
@@ -162,9 +166,7 @@ fun buildAnnotatedStringWithUrlHighlighting(
substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end }
if (inInsideRange != null) {
val percentInRange =
(offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt()
return inInsideRange.original.end
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
@@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.stringRes
@@ -70,6 +71,7 @@ fun MessageField(
state = viewModel.message,
onTextChanged = viewModel::onMessageChanged,
onContentReceived = onContentReceived,
inputTransformation = MentionPreservingInputTransformation,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -530,6 +531,7 @@ fun SendDirectMessageTo(
ThinPaddingTextField(
state = postViewModel.toUsers,
onTextChanged = postViewModel::onToUsersChanged,
inputTransformation = MentionPreservingInputTransformation,
modifier =
Modifier
.weight(1f)
@@ -572,6 +574,7 @@ fun SendDirectMessageTo(
ThinPaddingTextField(
state = postViewModel.subject,
onTextChanged = { postViewModel.onSubjectChanged() },
inputTransformation = MentionPreservingInputTransformation,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -53,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -203,6 +204,7 @@ fun EditField(
ThinPaddingTextField(
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
inputTransformation = MentionPreservingInputTransformation,
keyboardOptions = PostKeyboard,
shape = EditFieldBorder,
modifier = Modifier.fillMaxWidth(),
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -116,6 +117,7 @@ fun EditFieldRow(
ThinPaddingTextField(
state = channelScreenModel.message,
onTextChanged = { channelScreenModel.onMessageChanged() },
inputTransformation = MentionPreservingInputTransformation,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
@@ -381,6 +382,7 @@ private fun MarkdownPostScreenBody(
ThinPaddingTextField(
state = postViewModel.message,
onTextChanged = postViewModel::onMessageChanged,
inputTransformation = MentionPreservingInputTransformation,
modifier =
Modifier
.fillMaxWidth()
@@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
@@ -111,6 +112,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
ThinPaddingTextField(
state = postViewModel.title,
onTextChanged = { postViewModel.onTitleChanged() },
inputTransformation = MentionPreservingInputTransformation,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -311,6 +313,7 @@ fun SellProduct(postViewModel: NewProductViewModel) {
ThinPaddingTextField(
state = postViewModel.locationText,
onTextChanged = { postViewModel.onLocationChanged() },
inputTransformation = MentionPreservingInputTransformation,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -417,6 +418,7 @@ fun SendDirectMessageTo(
ThinPaddingTextField(
state = postViewModel.toUsers,
onTextChanged = postViewModel::onToUsersChanged,
inputTransformation = MentionPreservingInputTransformation,
modifier =
Modifier
.weight(1f)