From 9d2ce5e460a34f0be17bf3916f09ae7119bca516 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 17:37:18 +0000 Subject: [PATCH] fix(compose): snap cursor to wedge boundary in UrlUserTagTransformation The custom OffsetMapping for the @-mention VisualTransformation used percentage-based interpolation when the cursor offset fell inside a substituted "@npub1..." range. An IME using extracted-text mode (e.g. SwiftKey on Pixel 9a) could place the cursor in the middle of the displayed "@DisplayName", which mapped to the middle of the underlying bech32 npub. A subsequent backspace then deleted a char from inside the bech32, the npub stopped matching the regex's 58-char length check, and the collapsed mention "expanded" with the cursor stuck in the middle of the now-visible raw npub. Treat each substitution as an atomic wedge: any cursor strictly inside a substituted range snaps to the wedge's trailing edge in both directions. Tests are rewritten to verify the snap-to-boundary semantics; the prior assertions pinned the buggy percentage behavior. This fixes the cursor-jump-into-npub symptom in EditPostView and ForwardZapTo (which use VisualTransformation directly). Chat input fields use OutputTransformation with Compose's auto-derived mapping and are not affected by this code path. https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ --- .../amethyst/UrlUserTagTransformationTest.kt | 176 ++++++------------ .../ui/actions/UrlUserTagTransformation.kt | 14 +- 2 files changed, 60 insertions(+), 130 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 4a4a3ec15..b36312e75 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -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 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 - 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 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 - 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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 2bd2817af..a56eb7052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -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 }