Skip to content

Commit

Permalink
Fix Android visual transformation offset mapping (#164)
Browse files Browse the repository at this point in the history
* Fix Android visual transformation offset mapping

* Code review: Use kotlin test
  • Loading branch information
smatte authored Jun 27, 2023
1 parent 0adf657 commit db760e7
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,53 @@ internal class FormattedVisualTransformation(private val formatter: (text: Strin
VisualTransformation {

override fun filter(text: AnnotatedString): TransformedText {
val formattedString = formatter(text.text)
val originalText = text.text
val formattedString = formatter(originalText)
val offsetDelta = formattedString.length - text.length
return TransformedText(
text = AnnotatedString(formattedString),

val offsetMapping: OffsetMapping
if (offsetDelta > 0) {
val transformedToOriginalMapping = Array(formattedString.length) { -1 }
// First we fill mapping from original string
for (i in originalText.indices) {
transformedToOriginalMapping[formatter(originalText.substring(0, i + 1)).length - 1] = i
}
// Then we fills the missing indices to advance to the next offset
var nextOffset = originalText.length - 1
for (offset in transformedToOriginalMapping.indices.reversed()) {
if (transformedToOriginalMapping[offset] == -1) {
transformedToOriginalMapping[offset] = nextOffset
} else {
nextOffset = transformedToOriginalMapping[offset]
}
}

offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offset + offsetDelta
return offset + lengthDelta(offset)
}

override fun transformedToOriginal(offset: Int): Int {
return offset - offsetDelta
return if (offset < transformedToOriginalMapping.size) {
transformedToOriginalMapping[offset]
} else {
originalText.length
}
}

private fun lengthDelta(offset: Int): Int {
val originalSubstring = originalText.substring(0, Integer.min(offset + 1, originalText.length))
val formattedSubstring = formatter(originalSubstring)
return formattedSubstring.length - originalSubstring.length
}
}
} else {
offsetMapping = OffsetMapping.Identity
}

return TransformedText(
text = AnnotatedString(formattedString),
offsetMapping = offsetMapping
)
}
}
2 changes: 2 additions & 0 deletions trikot-viewmodels-declarative/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ dependencies {
api("androidx.compose.ui:ui-tooling:${Versions.JETPACK_COMPOSE_UI_TOOLING}")
api("io.coil-kt:coil-compose:${Versions.COIL}")

testImplementation(kotlin("test"))

implementation("org.jetbrains.kotlin:kotlin-reflect:${Versions.KOTLIN}")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,53 @@ internal class FormattedVisualTransformation(private val formatter: (text: Strin
VisualTransformation {

override fun filter(text: AnnotatedString): TransformedText {
val formattedString = formatter(text.text)
val originalText = text.text
val formattedString = formatter(originalText)
val offsetDelta = formattedString.length - text.length
return TransformedText(
text = AnnotatedString(formattedString),

val offsetMapping: OffsetMapping
if (offsetDelta > 0) {
val transformedToOriginalMapping = Array(formattedString.length) { -1 }
// First we fill mapping from original string
for (i in originalText.indices) {
transformedToOriginalMapping[formatter(originalText.substring(0, i + 1)).length - 1] = i
}
// Then we fills the missing indices to advance to the next offset
var nextOffset = originalText.length - 1
for (offset in transformedToOriginalMapping.indices.reversed()) {
if (transformedToOriginalMapping[offset] == -1) {
transformedToOriginalMapping[offset] = nextOffset
} else {
nextOffset = transformedToOriginalMapping[offset]
}
}

offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offset + offsetDelta
return offset + lengthDelta(offset)
}

override fun transformedToOriginal(offset: Int): Int {
return offset - offsetDelta
return if (offset < transformedToOriginalMapping.size) {
transformedToOriginalMapping[offset]
} else {
originalText.length
}
}

private fun lengthDelta(offset: Int): Int {
val originalSubstring = originalText.substring(0, Integer.min(offset + 1, originalText.length))
val formattedSubstring = formatter(originalSubstring)
return formattedSubstring.length - originalSubstring.length
}
}
} else {
offsetMapping = OffsetMapping.Identity
}

return TransformedText(
text = AnnotatedString(formattedString),
offsetMapping = offsetMapping
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.mirego.trikot.viewmodels

import androidx.compose.ui.text.AnnotatedString
import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.internal.FormattedVisualTransformation
import kotlin.test.Test
import kotlin.test.assertEquals

class FormattedVisualTransformationTest {

@Test
fun `Given original string "ABCDEFGHI" when transformed to "ABC DEF GHI" then returns the correct mapping positions`() {
val formattedVisualTransformation = FormattedVisualTransformation { text: String -> text.chunked(3).joinToString(" ") }
val annotatedString = AnnotatedString("ABCDEFGHI")
val filter = formattedVisualTransformation.filter(annotatedString)

assertEquals(0, filter.offsetMapping.transformedToOriginal(0))
assertEquals(1, filter.offsetMapping.transformedToOriginal(1))
assertEquals(2, filter.offsetMapping.transformedToOriginal(2))
assertEquals(3, filter.offsetMapping.transformedToOriginal(3))
assertEquals(3, filter.offsetMapping.transformedToOriginal(4))
assertEquals(4, filter.offsetMapping.transformedToOriginal(5))
assertEquals(5, filter.offsetMapping.transformedToOriginal(6))
assertEquals(6, filter.offsetMapping.transformedToOriginal(7))
assertEquals(6, filter.offsetMapping.transformedToOriginal(8))
assertEquals(7, filter.offsetMapping.transformedToOriginal(9))
assertEquals(8, filter.offsetMapping.transformedToOriginal(10))
assertEquals(9, filter.offsetMapping.transformedToOriginal(11))

assertEquals(0, filter.offsetMapping.originalToTransformed(0))
assertEquals(1, filter.offsetMapping.originalToTransformed(1))
assertEquals(2, filter.offsetMapping.originalToTransformed(2))
assertEquals(4, filter.offsetMapping.originalToTransformed(3))
assertEquals(5, filter.offsetMapping.originalToTransformed(4))
assertEquals(6, filter.offsetMapping.originalToTransformed(5))
assertEquals(8, filter.offsetMapping.originalToTransformed(6))
assertEquals(9, filter.offsetMapping.originalToTransformed(7))
assertEquals(10, filter.offsetMapping.originalToTransformed(8))
assertEquals(11, filter.offsetMapping.originalToTransformed(9))
}

@Test
fun `Given password string when transformed to * then returns the correct mapping positions`() {
val formattedVisualTransformation = FormattedVisualTransformation { text: String -> "*".repeat(text.length) }
val annotatedString = AnnotatedString("password")
val filter = formattedVisualTransformation.filter(annotatedString)

for (i in annotatedString.text.indices) {
assertEquals(i, filter.offsetMapping.transformedToOriginal(i))
assertEquals(i, filter.offsetMapping.originalToTransformed(i))
}
}

@Test
fun `When text is empty string then returns the correct mapping positions`() {
val formattedVisualTransformation = FormattedVisualTransformation { text: String -> text }
val annotatedString = AnnotatedString("")
val filter = formattedVisualTransformation.filter(annotatedString)

assertEquals(0, filter.offsetMapping.transformedToOriginal(0))
assertEquals(0, filter.offsetMapping.originalToTransformed(0))
}
}

0 comments on commit db760e7

Please sign in to comment.